diff --git a/src/App.vue b/src/App.vue index b6dd75210b..96e475cc2e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -775,6 +775,31 @@ h2 { color: $blue; } +.tags { + .tag { + background: var(--background-tag); + color: var(--text); + + a { + color: var(--text); + } + + .action { + cursor: pointer; + background: $light-grey; + color: white; + + .dark & { + background: $dark-grey; + } + + &:hover { + background: $dark-grey-lighter; + } + } + } +} + .timecode { border: 1px solid $blue; border-radius: 5px; diff --git a/src/components/lists/TaskStatusList.vue b/src/components/lists/TaskStatusList.vue index 48b7082dc4..e37034d6b5 100644 --- a/src/components/lists/TaskStatusList.vue +++ b/src/components/lists/TaskStatusList.vue @@ -28,6 +28,9 @@ {{ $t('task_status.fields.is_feedback_request') }} + + {{ $t('task_status.fields.for_concept') }} + @@ -52,6 +55,7 @@ class="is-feedback-request" :value="entry.is_feedback_request" /> + + + diff --git a/src/components/pages/Task.vue b/src/components/pages/Task.vue index d43cdd409e..e0c402aed2 100644 --- a/src/components/pages/Task.vue +++ b/src/components/pages/Task.vue @@ -265,6 +265,7 @@ :is-loading="loading.addExtraPreview" :is-error="errors.addExtraPreview" :form-data="addExtraPreviewFormData" + message="" :title=" task ? `${task.entity_name} / ${taskTypeMap.get(task.task_type_id).name}` diff --git a/src/components/pages/TaskTypes.vue b/src/components/pages/TaskTypes.vue index 65f72e5c88..1777071bba 100644 --- a/src/components/pages/TaskTypes.vue +++ b/src/components/pages/TaskTypes.vue @@ -117,7 +117,11 @@ export default { }, listTaskTypes() { - return this.isActiveTab ? this.taskTypes : this.archivedTaskTypes + const taskTypes = this.isActiveTab + ? this.taskTypes + : this.archivedTaskTypes + + return taskTypes.filter(taskType => taskType.for_entity !== 'Concept') } }, diff --git a/src/components/previews/PreviewPlayer.vue b/src/components/previews/PreviewPlayer.vue index 6413502871..815e7d822f 100644 --- a/src/components/previews/PreviewPlayer.vue +++ b/src/components/previews/PreviewPlayer.vue @@ -100,6 +100,7 @@ :silent="isCommentsHidden" :current-frame="currentFrame" :current-parent-preview="currentPreview" + :entity-type="entityType" @comment-added="$emit('comment-added')" @time-code-clicked="timeCodeClicked" v-show="!isCommentsHidden" @@ -205,7 +206,7 @@ -
+
@@ -263,7 +264,7 @@ class="flexrow-item flexrow-item" :title="$t('playlists.actions.annotation_redo')" icon="redo" - v-if="!readOnly && fullScreen" + v-if="!readOnly && fullScreen && !isConcept" @click="redoLastAction" /> @@ -272,7 +273,7 @@ icon="remove" :title="$t('playlists.actions.annotation_delete')" @click="onDeleteClicked" - v-if="!readOnly && fullScreen" + v-if="!readOnly && fullScreen && !isConcept" /> @@ -295,7 +296,7 @@ :active="isTyping" :title="$t('playlists.actions.annotation_text')" @click="onTypeClicked" - v-if="!readOnly && (!light || fullScreen)" + v-if="!readOnly && (!light || fullScreen) && !isConcept" /> @@ -326,7 +327,7 @@ :active="isDrawing" :title="$t('playlists.actions.annotation_draw')" @click="onPencilAnnotateClicked" - v-if="!readOnly && (!light || fullScreen)" + v-if="!readOnly && (!light || fullScreen) && !isConcept" /> -
-
- -
+
+
+
+
    +
  • + + {{ entity.name }} + + +
  • +
+
+
this.assetMap.get(id)) + .filter(Boolean) + }, + + onRemoveLink(link) { + const concept = { + id: this.currentConcept.id, + entity_concept_links: this.currentConcept.entity_concept_links.filter( + id => id !== link.id + ) + } + this.editConcept(concept) + }, + // Events onKeyDown(event) { @@ -2286,6 +2355,40 @@ export default { display: flex; } +.tags { + display: inline-flex; + flex-wrap: wrap; + gap: 10px; + padding: 1rem; + margin-left: 0; + font-weight: 500; + letter-spacing: 1px; + + .tag { + a { + display: inline-flex; + gap: 1em; + line-height: normal; + } + + .action { + border-radius: 50%; + display: none; + height: 14px; + width: 14px; + line-height: 8px; + } + + &:hover { + transform: scale(1.1); + + .action { + display: inline-block; + } + } + } +} + #resize-annotation-canvas, #annotation-snapshot { display: none; diff --git a/src/components/sides/TaskInfo.vue b/src/components/sides/TaskInfo.vue index a14676b57d..9f9193f664 100644 --- a/src/components/sides/TaskInfo.vue +++ b/src/components/sides/TaskInfo.vue @@ -45,7 +45,7 @@
-
+
-
+
@@ -442,6 +444,7 @@ export default { 'previewFormData', 'productionMap', 'selectedAssets', + 'selectedConcepts', 'selectedEdits', 'selectedShots', 'selectedTasks', @@ -459,7 +462,7 @@ export default { }, nbSelectedEntities() { - return this.selectedEntities ? this.selectedEntities.size : 0 + return this.selectedEntities?.size || 0 }, selectedEntities() { @@ -518,6 +521,10 @@ export default { return false }, + isConceptTask() { + return this.entityType === 'Concept' + }, + isPreviewPlayerReadOnly() { if (this.task) { if (this.isCurrentUserManager || this.isCurrentUserClient) { @@ -641,6 +648,15 @@ export default { return `/api/movies/originals/preview-files/${previewId}.mp4` }, + taskStatuses() { + const taskStatuses = this.getTaskStatusForCurrentUser( + this.task.project_id + ) + return taskStatuses.filter( + status => Boolean(status.for_concept) === this.isConceptTask + ) + }, + taskTypeStyle() { return getTaskTypeStyle(this.task) }, diff --git a/src/components/tops/ActionPanel.vue b/src/components/tops/ActionPanel.vue index 8138446ba9..e376a6ff04 100644 --- a/src/components/tops/ActionPanel.vue +++ b/src/components/tops/ActionPanel.vue @@ -70,7 +70,11 @@ active: selectedBar === 'thumbnails' }" :title="$t('menu.set_thumbnails')" - v-if="isTaskSelection && !this.isCurrentUserArtist" + v-if=" + isTaskSelection && + !this.isCurrentUserArtist && + !isCurrentViewConcept + " @click="selectBar('thumbnails')" > @@ -85,13 +89,30 @@ v-if=" isTaskSelection && !isCurrentViewSingleEntity && - !isCurrentViewTodos + !isCurrentViewTodos && + !isCurrentViewConcept " @click="selectBar('subscribe')" >
+ + + +
+
+

{{ $t('concepts.actions.title') }}

+
    +
  • + {{ $t('concepts.actions.empty') }} +
  • + +
+
+
+ +
+ +
+
+
+ +
+
+ + import { mapGetters, mapActions } from 'vuex' -import { intersection } from '@/lib/array' -import { sortPeople } from '@/lib/sorting' -import func from '@/lib/func' import { AlertCircleIcon, @@ -672,15 +802,25 @@ import { EyeIcon, FilmIcon, ImageIcon, + LinkIcon, PlayCircleIcon, TrashIcon, + Trash2Icon, UserIcon } from 'vue-feather-icons' + +import { intersection } from '@/lib/array' +import { sortPeople } from '@/lib/sorting' +import func from '@/lib/func' + +import BuildFilterModal from '@/components/modals/BuildFilterModal' +import ButtonSimple from '@/components/widgets/ButtonSimple' import ComboboxModel from '@/components/widgets/ComboboxModel' import ComboboxStatus from '@/components/widgets/ComboboxStatus' import ComboboxStyled from '@/components/widgets/ComboboxStyled' import DeleteEntities from '@/components/tops/actions/DeleteEntities' import PeopleField from '@/components/widgets/PeopleField' +import SearchField from '@/components/widgets/SearchField' import Spinner from '@/components/widgets/Spinner' import ViewPlaylistModal from '@/components/modals/ViewPlaylistModal' @@ -696,6 +836,8 @@ export default { components: { AlertCircleIcon, + BuildFilterModal, + ButtonSimple, CheckSquareIcon, ComboboxModel, ComboboxStatus, @@ -705,11 +847,14 @@ export default { EyeIcon, FilmIcon, ImageIcon, + LinkIcon, PeopleField, PlayCircleIcon, + SearchField, Spinner, UserIcon, TrashIcon, + Trash2Icon, ViewPlaylistModal }, @@ -727,6 +872,7 @@ export default { taskStatusId: '', statusComment: '', modals: { + buildFilter: false, playlist: false }, priorityOptions: [ @@ -752,17 +898,20 @@ export default { assetDeletion: false, changePriority: false, changeStatus: false, + conceptDeletion: false, editDeletion: false, episodeDeletion: false, taskCreation: false, taskDeletion: false, setThumbnails: false, shotDeletion: false, + links: false, tasksSubscription: false }, errors: { assetDeletion: false, taskDeletion: false, + conceptDeletion: false, editDeletion: false, episodeDeletion: false, shotDeletion: false @@ -784,6 +933,7 @@ export default { 'allCustomActions', 'assetMap', 'assetCustomActions', + 'assetsByType', 'currentProduction', 'getPersonOptions', 'isCurrentUserArtist', @@ -797,6 +947,7 @@ export default { 'personMap', 'productionMap', 'selectedAssets', + 'selectedConcepts', 'selectedEdits', 'selectedShots', 'selectedTasks', @@ -821,11 +972,19 @@ export default { currentEntityType() { if (this.isCurrentViewAsset) return 'asset' - else if (this.isCurrentViewShot) return 'shot' - else if (this.isCurrentViewEdit) return 'edit' + if (this.isCurrentViewShot) return 'shot' + if (this.isCurrentViewEdit) return 'edit' return 'episode' }, + currentConcept() { + return this.selectedConcepts.values().next().value + }, + + conceptLinkedEntities() { + return this.getLinkedEntities(this.currentConcept) + }, + defaultCustomAction() { if (this.customActions.length > 0) { return this.customActions[0] @@ -868,24 +1027,34 @@ export default { return this.selectedEdits.size }, + nbSelectedConcepts() { + return this.selectedConcepts.size + }, + isHidden() { return ( (this.nbSelectedTasks === 0 && this.nbSelectedValidations === 0 && this.nbSelectedAssets === 0 && this.nbSelectedShots === 0 && - this.nbSelectedEdits === 0) || + this.nbSelectedEdits === 0 && + this.nbSelectedConcepts === 0) || !( this.isCurrentViewAsset || this.isCurrentViewTodos || this.isCurrentViewShot || this.isCurrentViewEpisode || this.isCurrentViewSequence || - this.isCurrentViewEdit + this.isCurrentViewEdit || + this.isCurrentViewConcept ) ) }, + isConceptPublisher() { + return this.currentConcept?.created_by === this.user.id + }, + isCurrentViewSingleEntity() { return [ 'asset', @@ -901,36 +1070,38 @@ export default { }, isCurrentViewAsset() { - return ( - this.$route.path.indexOf('asset') > 0 && !this.$route.params.shot_id - ) + return this.$route.path.includes('asset') && !this.$route.params.shot_id }, isCurrentViewShot() { - return this.$route.path.indexOf('shot') > 0 && !this.$route.params.shot_id + return this.$route.path.includes('shot') && !this.$route.params.shot_id }, isCurrentViewEdit() { - return this.$route.path.indexOf('edit') > 0 && !this.$route.params.edit_id + return this.$route.path.includes('edit') && !this.$route.params.edit_id + }, + + isCurrentViewConcept() { + return this.$route.path.includes('concept') }, isCurrentViewTodos() { return ( - this.$route.path.indexOf('my-tasks') > 0 || - this.$route.path.indexOf('people/') > 0 + this.$route.path.includes('my-tasks') || + this.$route.path.includes('people/') ) }, isCurrentViewPerson() { - return this.$route.path.indexOf('people/') > 0 + return this.$route.path.includes('people/') }, isCurrentViewPersonTasks() { - return this.$route.path.indexOf('todos') > 0 + return this.$route.path.includes('todos') }, isCurrentViewTaskType() { - return this.$route.path.indexOf('task-type') > 0 + return this.$route.path.includes('task-type') }, isCurrentViewEntity() { @@ -949,7 +1120,7 @@ export default { this.isCurrentViewAsset || this.isCurrentViewShot || this.isCurrentViewEdit - ) && this.$route.path.indexOf('episodes') > 0 + ) && this.$route.path.includes('episodes') ) }, @@ -959,7 +1130,7 @@ export default { this.isCurrentViewAsset || this.isCurrentViewShot || this.isCurrentViewEdit - ) && this.$route.path.indexOf('sequences') > 0 + ) && this.$route.path.includes('sequences') ) }, @@ -995,30 +1166,57 @@ export default { storagePrefix() { let prefix = 'todos-' - if (this.isCurrentViewAsset || this.isCurrentViewShot) { + if ( + this.isCurrentViewAsset || + this.isCurrentViewShot || + this.isCurrentViewEdit + ) { prefix = 'entities-' } + if (this.isCurrentViewConcept) prefix = 'concepts-' if (this.isCurrentViewTaskType) prefix = 'tasks-' return prefix + }, + + availableLinksByType() { + const assetGroups = [...this.assetsByType] + const result = assetGroups + .map(assets => { + if (!assets.length) return + return { + type: assets[0].asset_type_name, + links: assets.map(asset => ({ + id: asset.id, + name: asset.name + })) + } + }) + .filter(Boolean) + return result } }, methods: { ...mapActions([ 'assignSelectedTasks', + 'changeSelectedTaskStatus', + 'changeSelectedPriorities', 'clearSelectedAssets', 'clearSelectedShots', 'clearSelectedEdits', + 'clearSelectedConcepts', + 'clearSelectedTasks', 'createSelectedTasks', 'deleteSelectedAssets', 'deleteSelectedShots', 'deleteSelectedTasks', 'deleteSelectedEdits', 'deleteSelectedEpisodes', - 'changeSelectedTaskStatus', - 'changeSelectedPriorities', - 'clearSelectedTasks', + 'deleteSelectedConcepts', + 'editConcept', + 'loadAssets', 'postCustomAction', + 'setAssetSearch', 'setLastTaskPreview', 'subscribeToTask', 'unassignPersonFromTask', @@ -1026,6 +1224,12 @@ export default { 'unsubscribeFromTask' ]), + getLinkedEntities(concept) { + return concept.entity_concept_links + .map(id => this.assetMap.get(id)) + .filter(Boolean) + }, + confirmTaskStatusChange() { this.loading.changeStatus = true if (!this.taskStatusId) { @@ -1099,14 +1303,13 @@ export default { }, confirmTaskCreation() { - const type = - this.$route.path.indexOf('shots') > 0 - ? 'shots' - : this.$route.path.indexOf('assets') > 0 - ? 'assets' - : this.$route.path.indexOf('edits') > 0 - ? 'edits' - : 'episodes' + const type = this.$route.path.includes('shots') + ? 'shots' + : this.$route.path.includes('assets') + ? 'assets' + : this.$route.path.includes('edits') + ? 'edits' + : 'episodes' this.loading.taskCreation = true this.createSelectedTasks({ type, @@ -1180,6 +1383,21 @@ export default { }) }, + confirmConceptDeletion() { + this.loading.deleteConcept = true + this.errors.deleteConcept = false + this.deleteSelectedConcepts() + .then(() => { + this.loading.deleteConcept = false + this.clearSelectedConcepts() + }) + .catch(err => { + console.error(err) + this.loading.deleteConcept = false + this.errors.deleteConcept = true + }) + }, + confirmPlaylistGeneration() { this.modals.playlist = true this.selectedBar = '' @@ -1319,27 +1537,36 @@ export default { this.selectedBar = 'delete-edits' return } + if (this.isCurrentViewConcept && this.nbSelectedConcepts > 1) { + this.selectedBar = 'delete-concepts' + return + } if (this.nbSelectedTasks === 1) { this.selectedBar = '' } - const prefix = this.storagePrefix - const lastSelection = localStorage.getItem(`${prefix}-selected-bar`) + const lastSelection = localStorage.getItem( + `${this.storagePrefix}-selected-bar` + ) if (lastSelection) { this.selectedBar = lastSelection - } else { - if (this.isCurrentViewAsset || this.isCurrentViewShot) { - this.selectedBar = 'change-status' - } + } else if ( + this.isCurrentViewAsset || + this.isCurrentViewShot || + this.isCurrentViewEdit + ) { + this.selectedBar = 'change-status' } } else { window.removeEventListener('keydown', this.onKeyDown) } }, - setAvailableStatus() { - if (this.selectedTasks.size === 0) this.availableTaskStatuses = [] - else if (this.isCurrentViewTodos) { + setAvailableStatuses() { + let availableTaskStatuses + if (this.selectedTasks.size === 0) { + availableTaskStatuses = [] + } else if (this.isCurrentViewTodos) { const productions = new Map() this.selectedTasks.forEach(task => { const project = this.productionMap.get(task.project_id) @@ -1349,12 +1576,46 @@ export default { p => p.task_statuses ) const availableStatus = new Set(intersection(statusLists)) - this.availableTaskStatuses = this.taskStatusForCurrentUser.filter( - status => availableStatus.has(status.id) + availableTaskStatuses = this.taskStatusForCurrentUser.filter(status => + availableStatus.has(status.id) ) } else { - this.availableTaskStatuses = this.taskStatusForCurrentUser + availableTaskStatuses = this.taskStatusForCurrentUser + } + availableTaskStatuses = availableTaskStatuses.filter( + status => Boolean(status.for_concept) === this.isCurrentViewConcept + ) + this.availableTaskStatuses = availableTaskStatuses + }, + + onRemoveLink(link) { + const concept = { + id: this.currentConcept.id, + entity_concept_links: this.currentConcept.entity_concept_links.filter( + id => id !== link.id + ) } + this.editConcept(concept) + }, + + confirmBuildFilter(query) { + this.modals.buildFilter = false + this.$refs['entity-search-field'].setValue(query) + this.onEntitySearchChange(query) + }, + + onEntitySearchChange(searchQuery) { + this.setAssetSearch(searchQuery) + }, + + onSelectLink(link) { + const concept = { + id: this.currentConcept.id, + entity_concept_links: [ + ...this.currentConcept.entity_concept_links + ].concat(link.id) + } + this.editConcept(concept) } }, @@ -1371,7 +1632,12 @@ export default { nbSelectedEdits() { this.autoChooseSelectBar() - if (this.nbSelectedShots > 0) this.clearSelectedTasks() + if (this.nbSelectedEdits > 0) this.clearSelectedTasks() + }, + + nbSelectedConcepts() { + this.autoChooseSelectBar() + if (this.nbSelectedConcepts > 1) this.clearSelectedTasks() }, isHidden() { @@ -1383,7 +1649,7 @@ export default { if (this.nbSelectedTasks > 0) { let isShotSelected = false let isAssetSelected = false - this.setAvailableStatus() + this.setAvailableStatuses() this.selectedTaskIds.forEach(taskId => { const task = this.selectedTasks.get(taskId) if (task && task.sequence_name) { @@ -1474,6 +1740,12 @@ export default { border-bottom: 1px solid $dark-grey-light; } } + + .concept-links { + background: $dark-grey-light; + border: 1px solid #222; + box-shadow: 0 0 6px #222; + } } .action-topbar { @@ -1604,4 +1876,75 @@ div.assignation { margin: auto; margin-top: 0.5em; } + +.tags { + display: inline-flex; + flex-wrap: wrap; + gap: 10px; + margin-left: 0; + min-height: 21px; + font-weight: 500; + letter-spacing: 1px; + + .tag { + display: inline-flex; + gap: 1em; + border: 1px solid $light-green; + + .action { + border-radius: 50%; + display: none; + height: 14px; + width: 14px; + line-height: 8px; + } + + &:hover { + transform: scale(1.1); + + .action { + display: inline-block; + } + } + } +} + +.concept-links { + overflow-y: auto; + padding: 1em; + background: $white; + border: 1px solid $white-grey; + box-shadow: 0 0 6px #e0e0e0; + border-radius: 1em; + + .subtitle { + margin-top: 0; + border-bottom: 0; + } + + .link-types { + list-style: none; + margin-left: 0; + } + + .link-type { + .subtitle { + text-transform: uppercase; + color: $grey; + border-bottom: 1px solid $light-grey; + font-size: 1.2em; + margin-top: 1em; + margin-bottom: 1em; + } + + .tag { + border-color: $light-grey; + cursor: pointer; + + &:hover { + transform: scale(1.1); + } + } + } +} diff --git a/src/components/tops/Topbar.vue b/src/components/tops/Topbar.vue index 8e9cd7af91..1e4d1f3022 100644 --- a/src/components/tops/Topbar.vue +++ b/src/components/tops/Topbar.vue @@ -441,6 +441,14 @@ export default { options.push({ label: 'Episodes', value: 'episodes' }) } + options = options.concat([{ label: 'separator', value: 'separator' }]) + + if (!this.isCurrentUserClient) { + options = options.concat([ + { label: this.$t('concepts.title'), value: 'concepts' } + ]) + } + if (!this.isCurrentUserClient) { options = options.concat([ { label: this.$t('breakdown.title'), value: 'breakdown' } diff --git a/src/components/tops/actions/DeleteEntities.vue b/src/components/tops/actions/DeleteEntities.vue index 63c484d808..2ab080b747 100644 --- a/src/components/tops/actions/DeleteEntities.vue +++ b/src/components/tops/actions/DeleteEntities.vue @@ -15,8 +15,6 @@ diff --git a/src/components/widgets/AddComment.vue b/src/components/widgets/AddComment.vue index 019f75e5b7..045484904c 100644 --- a/src/components/widgets/AddComment.vue +++ b/src/components/widgets/AddComment.vue @@ -32,6 +32,7 @@ active: mode === 'publish' }" @click="mode = 'publish'" + v-if="!isConcept" > {{ $t('tasks.publish_revision') }} @@ -457,6 +458,10 @@ export default { return status.is_retake && this.checklist.length === 0 }, + isConcept() { + return this.$route.path.includes('concept') + }, + isValidForm() { return ( this.mode === 'status' || diff --git a/src/components/widgets/Comment.vue b/src/components/widgets/Comment.vue index 445974af65..be5a75ef08 100644 --- a/src/components/widgets/Comment.vue +++ b/src/components/widgets/Comment.vue @@ -270,7 +270,7 @@
- - {{ searchQuery.name }} - + {{ searchQuery.name }} diff --git a/src/lib/path.js b/src/lib/path.js index 8a8f1fe600..68f9e21a0f 100644 --- a/src/lib/path.js +++ b/src/lib/path.js @@ -71,7 +71,13 @@ export const getEntitiesPath = (productionId, type, episodeId) => { return route } -export const getEntityPath = (entityId, productionId, section, episodeId) => { +export const getEntityPath = ( + entityId, + productionId, + section, + episodeId, + query +) => { const route = { name: section, params: { @@ -88,6 +94,10 @@ export const getEntityPath = (entityId, productionId, section, episodeId) => { route.params[`${section}_id`] = entityId } + if (query) { + route.query = query + } + return route } @@ -117,7 +127,8 @@ export const getProductionPath = ( 'quota', 'team', 'episodes', - 'episode-stats' + 'episode-stats', + 'concepts' ].includes(section) ) { route = episodifyRoute(route, episodeId || 'all') diff --git a/src/locales/en.js b/src/locales/en.js index a2f87d67f9..a6c878dca6 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -18,6 +18,7 @@ export default { new_assets: 'Create assets', new_success: 'Asset {name} successfully created.', no_cast_in: 'This asset is not cast in any shot.', + no_concept: 'No concepts linked to this asset', number: 'asset | assets', restore_text: 'Are you sure you want to restore {name} from your archive?', restore_error: 'An error occurred while restoring this asset.', @@ -134,6 +135,27 @@ export default { } }, + concepts: { + add_new_concept: 'Add a new reference to concepts', + add_concept: 'Add files for new concepts', + add_concept_error: 'An error occurred while adding concept.', + delete_for_selection: 'Delete the selected concept | Delete the {nbSelectedConcepts} selected concepts', + empty: 'There are no concepts', + multiple_delete_error: 'An error occurred while deleting a concept. There is probably some data linked to a concept. Are you sure there is no task linked to a selected concept?', + title: 'Concepts', + fields: { + entity_type: 'Entity type', + created_at: 'Creation date', + updated_at: 'Update date', + last_comment_date: 'Last comment', + publisher: 'Publish by', + }, + actions: { + title: 'Links to Concept', + empty: 'No links' + } + }, + custom_actions: { create_error: 'An error occurred while saving this custom action. Are you sure that there is no other action with the same name?', delete_text: 'Are you sure you want to remove custom action {name} from your database?', @@ -572,14 +594,16 @@ export default { change_status: 'Change status', create_tasks: 'Create tasks', delete_assets: 'Delete assets', - delete_shots: 'Delete shots', + delete_concepts: 'Delete concepts', delete_edits: 'Delete edits', + delete_shots: 'Delete shots', delete_tasks: 'Delete tasks', + edit_concepts: "Edit concept links", generate_playlist: 'Generate a playlist', run_custom_action: 'Run custom action', set_estimations: 'Set estimations', set_thumbnails: 'Set thumbnails from last preview', - subscribe: 'Subscribe to notifications' + subscribe: 'Subscribe to notifications', }, news: { @@ -1063,6 +1087,7 @@ export default { title: 'Task Status', fields: { color: 'Color', + for_concept: 'For concept', is_artist_allowed: 'Is artist allowed', is_client_allowed: 'Is client allowed', is_done: 'Is done', diff --git a/src/router/routes.js b/src/router/routes.js index 114c9f5779..f8085ffe61 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -25,6 +25,7 @@ const Asset = () => import('@/components/pages/Asset.vue') const AssetTypes = () => import('@/components/pages/AssetTypes.vue') const Backgrounds = () => import('@/components/pages/Backgrounds.vue') const Breakdown = () => import('@/components/pages/Breakdown.vue') +const Concepts = () => import('@/components/pages/Concepts.vue') const CustomActions = () => import('@/components/pages/CustomActions.vue') const Departments = () => import('@/components/pages/Departments.vue') const Edit = () => import('@/components/pages/Edit.vue') @@ -531,6 +532,12 @@ export const routes = [ ] }, + { + path: 'productions/:production_id/concepts', + component: Concepts, + name: 'concepts' + }, + { path: 'productions/:production_id/assets', component: Assets, diff --git a/src/store/api/concepts.js b/src/store/api/concepts.js new file mode 100644 index 0000000000..1a17179008 --- /dev/null +++ b/src/store/api/concepts.js @@ -0,0 +1,38 @@ +import client from '@/store/api/client' + +export default { + getConcepts(production) { + return client.pget( + `/api/data/concepts/with-tasks?project_id=${production.id}` + ) + }, + + getConcept(conceptId) { + return client.getModel('concepts', conceptId, true) + }, + + newConcept(concept) { + const data = { + name: concept.name, + description: concept.description + } + return client.ppost( + `/api/data/projects/${concept.project_id}/concepts`, + data + ) + }, + + updateConcept(concept) { + return client.pput(`/api/data/entities/${concept.id}`, concept) + }, + + deleteConcept(concept) { + return client.pdel(`/api/data/concepts/${concept.id}?force=true`) + }, + + getEntityLinked(entity) { + return client.pget( + `/api/data/entities/${entity.id}/entities-linked/with-tasks` + ) + } +} diff --git a/src/store/api/taskstatus.js b/src/store/api/taskstatus.js index 0bc9f50a15..f1c6423baa 100644 --- a/src/store/api/taskstatus.js +++ b/src/store/api/taskstatus.js @@ -4,6 +4,7 @@ const sanitizeTaskStatus = taskStatus => { return { name: taskStatus.name, short_name: taskStatus.short_name, + for_concept: Boolean(taskStatus.for_concept === 'true'), is_default: Boolean(taskStatus.is_default === 'true'), is_done: Boolean(taskStatus.is_done === 'true'), is_retake: Boolean(taskStatus.is_retake === 'true'), diff --git a/src/store/index.js b/src/store/index.js index 59dd6a3cf3..dbb6349c45 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -7,6 +7,7 @@ import assets from '@/store/modules/assets' import assetTypes from '@/store/modules/assettypes' import backgrounds from '@/store/modules/backgrounds' import breakdown from '@/store/modules/breakdown' +import concepts from '@/store/modules/concepts' import customActions from '@/store/modules/customactions' import departments from '@/store/modules/departments' import edits from '@/store/modules/edits' @@ -35,6 +36,7 @@ const modules = { assetTypes, backgrounds, breakdown, + concepts, customActions, departments, edits, diff --git a/src/store/modules/concepts.js b/src/store/modules/concepts.js new file mode 100644 index 0000000000..ae6ce26b41 --- /dev/null +++ b/src/store/modules/concepts.js @@ -0,0 +1,255 @@ +import async from 'async' + +import conceptsApi from '@/store/api/concepts' + +import { + LOAD_CONCEPTS_START, + LOAD_CONCEPTS_ERROR, + LOAD_CONCEPTS_END, + EDIT_CONCEPT_END, + DELETE_CONCEPT_END, + ADD_SELECTED_CONCEPTS, + CLEAR_SELECTED_CONCEPTS, + LOAD_LINKED_CONCEPTS_START, + LOAD_LINKED_CONCEPTS_ERROR, + LOAD_LINKED_CONCEPTS_END, + RESET_ALL +} from '@/store/mutation-types' + +const helpers = { + populateTask(task, concept) { + Object.assign(task, { + entity_name: '', + project_id: concept.project_id + }) + return task + }, + + populateConcept(concept) { + concept.full_name = 'Concept' + concept.last_comment_date = concept.tasks?.[0]?.last_comment_date + concept.tasks?.forEach(task => { + helpers.populateTask(task, concept) + }) + } +} + +const initialState = { + concepts: [], + conceptMap: new Map(), + conceptSearchText: '', + conceptSearchQueries: [], + displayedConcepts: [], + linkedConcepts: [], + selectedConcepts: new Map() +} + +const state = { + ...initialState +} + +const getters = { + concepts: state => state.concepts, + conceptMap: state => state.conceptMap, + displayedConcepts: state => state.displayedConcepts, + linkedConcepts: state => state.linkedConcepts, + selectedConcepts: state => state.selectedConcepts +} + +const actions = { + async loadConcepts({ commit, rootGetters }) { + commit(LOAD_CONCEPTS_START) + try { + const production = rootGetters.currentProduction + const concepts = await conceptsApi.getConcepts(production) + commit(LOAD_CONCEPTS_END, { concepts }) + } catch (err) { + console.error(err) + commit(LOAD_CONCEPTS_ERROR) + } + }, + + async loadConcept({ commit }, conceptId) { + try { + const concept = await conceptsApi.getConcept(conceptId) + commit(EDIT_CONCEPT_END, concept) + } catch (err) { + console.error(err) + } + }, + + async newConcepts({ dispatch }, forms) { + return Promise.all(forms.map(form => dispatch('newConcept', form))) + }, + + async newConcept({ commit, dispatch, rootGetters }, form) { + const production = rootGetters.currentProduction + + // Create Entity + const entity = { + name: crypto.randomUUID(), // unique and mandatory field + project_id: production.id + } + const concept = await conceptsApi.newConcept(entity) + + // Create Task + const conceptTaskType = rootGetters.taskTypes.find( + taskType => taskType.for_entity === 'Concept' + ) + const task = await dispatch('createTask', { + entityId: concept.id, + projectId: production.id, + taskTypeId: conceptTaskType.id, + type: 'concepts' + }) + + // Create Comment with Preview + const { preview } = await dispatch('commentTaskWithPreview', { + taskId: task.id, + taskStatusId: task.task_status_id, + form + }) + await dispatch('setLastTaskPreview', task.id) + + concept.tasks = [task] + concept.preview_file_id = preview.id + helpers.populateConcept(concept) + + commit(EDIT_CONCEPT_END, concept) + return concept + }, + + async editConcept({ commit }, data) { + const concept = await conceptsApi.updateConcept(data) + commit(EDIT_CONCEPT_END, concept) + return concept + }, + + async deleteConcept({ commit }, concept) { + await conceptsApi.deleteConcept(concept) + commit(DELETE_CONCEPT_END, concept) + }, + + addSelectedConcepts({ commit }, concept) { + commit(ADD_SELECTED_CONCEPTS, concept) + }, + + deleteSelectedConcepts({ state, dispatch }) { + return new Promise((resolve, reject) => { + let selectedConceptIds = [...state.selectedConcepts.values()] + .filter(concept => !concept.canceled) + .map(concept => concept.id) + if (selectedConceptIds.length === 0) { + selectedConceptIds = [...state.selectedConcepts.keys()] + } + async.eachSeries( + selectedConceptIds, + (conceptId, next) => { + const concept = state.conceptMap.get(conceptId) + if (concept) { + dispatch('deleteConcept', concept) + } + next() + }, + err => { + if (err) reject(err) + else { + resolve() + } + } + ) + }) + }, + + clearSelectedConcepts({ commit }) { + commit(CLEAR_SELECTED_CONCEPTS) + }, + + async loadLinkedConcepts({ commit }, entity) { + commit(LOAD_LINKED_CONCEPTS_START) + try { + const concepts = await conceptsApi.getEntityLinked(entity) + commit(LOAD_LINKED_CONCEPTS_END, { concepts }) + } catch (err) { + console.error(err) + commit(LOAD_LINKED_CONCEPTS_ERROR) + } + } +} + +const mutations = { + [LOAD_CONCEPTS_START](state) { + state.concepts = [] + state.conceptMap = new Map() + state.displayedConcepts = [] + }, + + [LOAD_CONCEPTS_ERROR](state) { + state.concepts = [] + state.conceptMap = new Map() + state.displayedConcepts = [] + }, + + [LOAD_CONCEPTS_END](state, { concepts }) { + concepts.forEach(helpers.populateConcept) + state.concepts = concepts + state.conceptMap = new Map(concepts.map(concept => [concept.id, concept])) + state.displayedConcepts = concepts + }, + + [EDIT_CONCEPT_END](state, newConcept) { + const concept = state.conceptMap.get(newConcept.id) + if (concept?.id) { + Object.assign(concept, newConcept) + state.conceptMap.delete(concept.id) + state.conceptMap.set(concept.id, concept) + } else { + state.concepts.push(newConcept) + state.conceptMap.set(newConcept.id, newConcept) + } + state.conceptMap = new Map(state.conceptMap) // for reactivity + }, + + [DELETE_CONCEPT_END](state, concept) { + const conceptIndex = state.concepts.findIndex(({ id }) => id === concept.id) + if (conceptIndex >= 0) { + state.concepts.splice(conceptIndex, 1) + } + delete state.conceptMap.get(concept.id) + }, + + [ADD_SELECTED_CONCEPTS](state, concepts) { + concepts.forEach(concept => { + state.selectedConcepts.set(concept.id, concept) + }) + state.selectedConcepts = new Map(state.selectedConcepts) // for reactivity + }, + + [CLEAR_SELECTED_CONCEPTS](state) { + state.selectedConcepts = new Map() + }, + + [LOAD_LINKED_CONCEPTS_START](state) { + state.linkedConcepts = [] + }, + + [LOAD_LINKED_CONCEPTS_ERROR](state) { + state.linkedConcepts = [] + }, + + [LOAD_LINKED_CONCEPTS_END](state, { concepts }) { + concepts.forEach(helpers.populateConcept) + state.linkedConcepts = concepts + }, + + [RESET_ALL](state) { + Object.assign(state, { ...initialState }) + } +} + +export default { + state, + getters, + actions, + mutations +} diff --git a/src/store/mutation-types.js b/src/store/mutation-types.js index af46a2fe04..c1a74b4944 100644 --- a/src/store/mutation-types.js +++ b/src/store/mutation-types.js @@ -636,3 +636,16 @@ export const LOAD_BACKGROUNDS_ERROR = 'LOAD_BACKGROUNDS_ERROR' export const LOAD_BACKGROUNDS_END = 'LOAD_BACKGROUNDS_END' export const EDIT_BACKGROUND_END = 'EDIT_BACKGROUND_END' export const DELETE_BACKGROUND_END = 'DELETE_BACKGROUND_END' + +// Concepts + +export const LOAD_CONCEPTS_START = 'LOAD_CONCEPTS_START' +export const LOAD_CONCEPTS_ERROR = 'LOAD_CONCEPTS_ERROR' +export const LOAD_CONCEPTS_END = 'LOAD_CONCEPTS_END' +export const EDIT_CONCEPT_END = 'EDIT_CONCEPT_END' +export const DELETE_CONCEPT_END = 'DELETE_CONCEPT_END' +export const ADD_SELECTED_CONCEPTS = 'ADD_SELECTED_CONCEPTS' +export const CLEAR_SELECTED_CONCEPTS = 'CLEAR_SELECTED_CONCEPTS' +export const LOAD_LINKED_CONCEPTS_START = 'LOAD_LINKED_CONCEPTS_START' +export const LOAD_LINKED_CONCEPTS_ERROR = 'LOAD_LINKED_CONCEPTS_ERROR' +export const LOAD_LINKED_CONCEPTS_END = 'LOAD_LINKED_CONCEPTS_END' diff --git a/src/testrouter/routes.js b/src/testrouter/routes.js index 3ad734d323..d0a2a1f3d1 100644 --- a/src/testrouter/routes.js +++ b/src/testrouter/routes.js @@ -25,6 +25,7 @@ import Asset from '@/components/pages/Asset' import AssetTypes from '@/components/pages/AssetTypes' import Backgrounds from '@/components/pages/Backgrounds' import Breakdown from '@/components/pages/Breakdown' +import Concepts from '@/components/pages/Concepts' import CustomActions from '@/components/pages/CustomActions' import Departments from '@/components/pages/departments/Departments' import Edit from '@/components/pages/Edit' @@ -591,6 +592,12 @@ export const routes = [ ] }, + { + path: 'productions/:production_id/concepts', + component: Concepts, + name: 'concepts' + }, + { path: 'productions/:production_id/assets', component: Assets, diff --git a/tests/unit/lib/path.spec.js b/tests/unit/lib/path.spec.js index 6b8374f646..b655406f3f 100644 --- a/tests/unit/lib/path.spec.js +++ b/tests/unit/lib/path.spec.js @@ -191,6 +191,16 @@ describe('path', () => { asset_id: 1 } }) + expect(getEntityPath(1, 2, 'asset', null, { param: 'test' })).toEqual({ + name: 'asset', + params: { + production_id: 2, + asset_id: 1 + }, + query: { + param: 'test' + } + }) expect(getEntityPath(1, 2, 'shot', 3)).toEqual({ name: 'episode-shot', params: {