From bae39ce01df4dbba507760a13f6aa0272cdc9004 Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Wed, 13 Mar 2019 13:53:40 +0100 Subject: [PATCH] Attract multi edit: Edit multiple tasks/shots/assets at the same time For the user: Ctrl + L-Mouse to select multiple tasks/shots/assets and then edit the nodes as before. When multiple items are selected a chain icon can be seen in editor next to the fields. If the chain is broken it indicates that the values are not the same on all the selected items. When a field has been edited it will be marked with a green background color. The items are saved one by one in parallel. This means that one item could fail to be saved, while the others get updated. For developers: The editor and activities has been ported to Vue. The table and has been updated to support multi select. MultiEditEngine is the core of the multi edit. It keeps track of what values differs and what has been edited. --- attract/shots_and_assets/routes_assets.py | 15 +- attract/shots_and_assets/routes_shots.py | 15 +- attract/tasks/routes.py | 11 +- src/scripts/js/es6/common/api/tasks.js | 3 +- src/scripts/js/es6/common/auth/auth.js | 11 + .../js/es6/common/vuecomponents/App.js | 161 +++++++++ .../js/es6/common/vuecomponents/EventBus.js | 7 - .../vuecomponents/activities/Activities.js | 51 +++ .../vuecomponents/activities/Activity.js | 29 ++ .../common/vuecomponents/assetstable/Table.js | 11 +- .../assetstable/rows/AssetRow.js | 16 +- .../assetstable/rows/AssetRowsSource.js | 2 +- .../cells/renderer/CellRowObject.js | 12 +- .../attracttable/cells/renderer/CellTasks.js | 58 +--- .../cells/renderer/CellTasksLink.js | 33 ++ .../attracttable/columns/TaskDueDate.js | 6 +- .../attracttable/rows/AttractRowBase.js | 4 +- .../rows/AttractRowsSourceBase.js | 10 +- .../attracttable/rows/TaskEventListener.js | 28 +- .../vuecomponents/detailedview/Empty.js | 9 + .../detailedview/MultipleTypes.js | 11 + .../vuecomponents/detailedview/Viewer.js | 114 +++++++ .../vuecomponents/editor/AssetEditor.js | 113 ++++++ .../common/vuecomponents/editor/ShotEditor.js | 246 +++++++++++++ .../common/vuecomponents/editor/TaskEditor.js | 244 +++++++++++++ .../editor/base/ConclusiveMark.js | 42 +++ .../vuecomponents/editor/base/DatePicker.js | 50 +++ .../vuecomponents/editor/base/EditorBase.js | 128 +++++++ .../editor/base/MultiEditEngine.js | 323 ++++++++++++++++++ .../vuecomponents/editor/base/Select2.js | 56 +++ .../vuecomponents/editor/base/TextArea.js | 31 ++ .../js/es6/common/vuecomponents/init.js | 3 + .../common/vuecomponents/shotstable/Table.js | 4 +- .../vuecomponents/shotstable/rows/ShotRow.js | 16 +- .../shotstable/rows/ShotRowsSource.js | 2 +- .../common/vuecomponents/taskstable/Table.js | 11 +- .../taskstable/cells/ParentName.js | 8 +- .../vuecomponents/taskstable/rows/TaskRow.js | 6 +- .../taskstable/rows/TaskRowsSource.js | 2 +- src/scripts/tutti/10_tasks.js | 256 +------------- src/styles/_app_base.sass | 19 ++ src/styles/components/_attract_table.sass | 3 + src/templates/attract/assets/for_project.pug | 31 +- .../attract/assets/view_asset_embed.pug | 85 +---- src/templates/attract/layout.pug | 2 +- src/templates/attract/shots/for_project.pug | 33 +- src/templates/attract/tasks/for_project.pug | 36 +- src/templates/attract/tasks/for_user.pug | 4 - .../attract/tasks/task_list_for_user.pug | 24 ++ 49 files changed, 1879 insertions(+), 516 deletions(-) create mode 100644 src/scripts/js/es6/common/vuecomponents/App.js delete mode 100644 src/scripts/js/es6/common/vuecomponents/EventBus.js create mode 100644 src/scripts/js/es6/common/vuecomponents/activities/Activities.js create mode 100644 src/scripts/js/es6/common/vuecomponents/activities/Activity.js create mode 100644 src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellTasksLink.js create mode 100644 src/scripts/js/es6/common/vuecomponents/detailedview/Empty.js create mode 100644 src/scripts/js/es6/common/vuecomponents/detailedview/MultipleTypes.js create mode 100644 src/scripts/js/es6/common/vuecomponents/detailedview/Viewer.js create mode 100644 src/scripts/js/es6/common/vuecomponents/editor/AssetEditor.js create mode 100644 src/scripts/js/es6/common/vuecomponents/editor/ShotEditor.js create mode 100644 src/scripts/js/es6/common/vuecomponents/editor/TaskEditor.js create mode 100644 src/scripts/js/es6/common/vuecomponents/editor/base/ConclusiveMark.js create mode 100644 src/scripts/js/es6/common/vuecomponents/editor/base/DatePicker.js create mode 100644 src/scripts/js/es6/common/vuecomponents/editor/base/EditorBase.js create mode 100644 src/scripts/js/es6/common/vuecomponents/editor/base/MultiEditEngine.js create mode 100644 src/scripts/js/es6/common/vuecomponents/editor/base/Select2.js create mode 100644 src/scripts/js/es6/common/vuecomponents/editor/base/TextArea.js diff --git a/attract/shots_and_assets/routes_assets.py b/attract/shots_and_assets/routes_assets.py index 6c92334..6ce8ae5 100644 --- a/attract/shots_and_assets/routes_assets.py +++ b/attract/shots_and_assets/routes_assets.py @@ -24,16 +24,17 @@ log = logging.getLogger(__name__) @perproject_blueprint.route('/with-task/', endpoint='with_task') @attract_project_view(extension_props=True) def for_project(project, attract_props, task_id=None, asset_id=None): - can_create = current_attract.auth.current_user_may(current_attract.auth.Actions.USE) + can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE) navigation_links = project_navigation_links(project, pillar_api()) extension_sidebar_links = current_app.extension_sidebar_links(project) + selected_id = asset_id or task_id return render_template('attract/assets/for_project.html', - open_task_id=task_id, - open_asset_id=asset_id, + selected_id=selected_id, project=project, - can_create_task=can_create, - can_create_asset=can_create, + can_use_attract=can_use_attract, + can_create_task=can_use_attract, + can_create_asset=can_use_attract, navigation_links=navigation_links, extension_sidebar_links=extension_sidebar_links, ) @@ -48,13 +49,15 @@ def view_asset(project, attract_props, asset_id): asset, node_type = routes_common.view_node(project, asset_id, node_type_asset['name']) auth = current_attract.auth - can_edit = auth.current_user_may(auth.Actions.USE) and 'PUT' in asset.allowed_methods + can_use_attract = auth.current_user_may(auth.Actions.USE) + can_edit = can_use_attract and 'PUT' in asset.allowed_methods return render_template('attract/assets/view_asset_embed.html', asset=asset, project=project, asset_node_type=node_type, attract_props=attract_props, + can_use_attract=can_use_attract, can_edit=can_edit) diff --git a/attract/shots_and_assets/routes_shots.py b/attract/shots_and_assets/routes_shots.py index dd4c365..6836e12 100644 --- a/attract/shots_and_assets/routes_shots.py +++ b/attract/shots_and_assets/routes_shots.py @@ -39,20 +39,22 @@ def for_project(project, attract_props, task_id=None, shot_id=None): for shot in shots if shot.properties.used_in_edit), } - can_create_task = current_attract.auth.current_user_may(current_attract.auth.Actions.USE) + can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE) navigation_links = project_navigation_links(project, pillar_api()) extension_sidebar_links = current_app.extension_sidebar_links(project) + selected_id = shot_id or task_id + return render_template('attract/shots/for_project.html', shots=shots, tasks_for_shots=tasks_for_shots, task_types=task_types_for_template, - open_task_id=task_id, - open_shot_id=shot_id, + selected_id=selected_id, project=project, attract_props=attract_props, stats=stats, - can_create_task=can_create_task, + can_use_attract=can_use_attract, + can_create_task=can_use_attract, navigation_links=navigation_links, extension_sidebar_links=extension_sidebar_links) @@ -64,14 +66,15 @@ def view_shot(project, attract_props, shot_id): return for_project(project, attract_props, shot_id=shot_id) shot, node_type = routes_common.view_node(project, shot_id, node_type_shot['name']) - can_edit = current_attract.auth.current_user_may(current_attract.auth.Actions.USE) + can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE) return render_template('attract/shots/view_shot_embed.html', shot=shot, project=project, shot_node_type=node_type, attract_props=attract_props, - can_edit=can_edit and 'PUT' in shot.allowed_methods) + can_use_attract=can_use_attract, + can_edit=can_use_attract and 'PUT' in shot.allowed_methods) @perproject_blueprint.route('/', methods=['POST']) diff --git a/attract/tasks/routes.py b/attract/tasks/routes.py index 0ec035c..5930faf 100644 --- a/attract/tasks/routes.py +++ b/attract/tasks/routes.py @@ -53,15 +53,16 @@ def delete(task_id): @attract_project_view(extension_props=False) def for_project(project, task_id=None): tasks = current_attract.task_manager.tasks_for_project(project['_id']) - can_create_task = current_attract.auth.current_user_may(current_attract.auth.Actions.USE) + can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE) navigation_links = project_navigation_links(project, pillar_api()) extension_sidebar_links = current_app.extension_sidebar_links(project) return render_template('attract/tasks/for_project.html', tasks=tasks['_items'], - open_task_id=task_id, + selected_id=task_id, project=project, - can_create_task=can_create_task, + can_use_attract=can_use_attract, + can_create_task=can_use_attract, navigation_links=navigation_links, extension_sidebar_links=extension_sidebar_links) @@ -89,7 +90,8 @@ def view_task(project, attract_props, task_id): # Fetch project users so that we can assign them tasks auth = current_attract.auth - can_edit = 'PUT' in task.allowed_methods and auth.current_user_may(auth.Actions.USE) + can_use_attract = auth.current_user_may(auth.Actions.USE) + can_edit = 'PUT' in task.allowed_methods and can_use_attract if can_edit: users = project.get_users(api=api) @@ -110,6 +112,7 @@ def view_task(project, attract_props, task_id): task_types=task_types, attract_props=attract_props.to_dict(), attract_context=request.args.get('context'), + can_use_attract=can_use_attract, can_edit=can_edit) diff --git a/src/scripts/js/es6/common/api/tasks.js b/src/scripts/js/es6/common/api/tasks.js index 739d9ce..3d31eae 100644 --- a/src/scripts/js/es6/common/api/tasks.js +++ b/src/scripts/js/es6/common/api/tasks.js @@ -14,7 +14,8 @@ function thenGetProjectTasks(projectId) { let embedded = { parent: 1 } - return pillar.api.thenGetNodes(where, embedded); + let sort = 'parent'; + return pillar.api.thenGetNodes(where, embedded, sort); } export { thenGetTasks, thenGetProjectTasks } diff --git a/src/scripts/js/es6/common/auth/auth.js b/src/scripts/js/es6/common/auth/auth.js index fd04ac4..671813b 100644 --- a/src/scripts/js/es6/common/auth/auth.js +++ b/src/scripts/js/es6/common/auth/auth.js @@ -2,6 +2,7 @@ class ProjectAuth { constructor() { this.canCreateTask = false; this.canCreateAsset = false; + this.canUseAttract = false; } } @@ -20,6 +21,16 @@ class Auth { return projectAuth.canCreateAsset; } + canUserCanUseAttract(projectId) { + let projectAuth = this.getProjectAuth(projectId); + return projectAuth.canUseAttract; + } + + setUserCanUseAttract(projectId, canUseAttract) { + let projectAuth = this.getProjectAuth(projectId); + projectAuth.canUseAttract = canUseAttract; + } + setUserCanCreateTask(projectId, canCreateTask) { let projectAuth = this.getProjectAuth(projectId); projectAuth.canCreateTask = canCreateTask; diff --git a/src/scripts/js/es6/common/vuecomponents/App.js b/src/scripts/js/es6/common/vuecomponents/App.js new file mode 100644 index 0000000..d69e61d --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/App.js @@ -0,0 +1,161 @@ +import { AssetsTable } from './assetstable/Table' +import { TasksTable } from './taskstable/Table' +import { ShotsTable } from './shotstable/Table' +import './detailedview/Viewer' +const BrowserHistoryState = pillar.vuecomponents.mixins.BrowserHistoryState; +const StateSaveMode = pillar.vuecomponents.mixins.StateSaveMode; + +const TEMPLATE =` +
+
+ +
+
+ +
+`; + +Vue.component('attract-app', { + template: TEMPLATE, + mixins: [BrowserHistoryState], + props: { + projectId: String, + selectedIds: { + type: Array, + default: [] + }, + contextType: { + type: String, + default: 'shots', + } + }, + data() { + return { + selectedItems: [], + isEditing: false, + isTableInited: false, + project: null + } + }, + created() { + pillar.api.thenGetProject(this.projectId) + .then((project) =>{ + this.project = project; + }); + }, + computed: { + selectedNames() { + return this.selectedItems.map(it => it.name); + }, + tableComponentName() { + switch (this.contextType) { + case 'assets': return AssetsTable.options.name; + case 'tasks': return TasksTable.options.name; + case 'shots': return ShotsTable.options.name; + default: + console.log('Unknown context type', this.contextType); + return ShotsTable.$options.name; + } + }, + /** + * @override BrowserHistoryState + */ + browserHistoryState() { + if(this.isTableInited) { + return { + 'selectedIds': this.selectedIds + }; + } else { + return {}; + } + }, + /** + * @override BrowserHistoryState + */ + historyStateUrl() { + let projectUrl = ProjectUtils.projectUrl(); + if(this.selectedItems.length !== 1) { + return `/attract/${projectUrl}/${this.contextType}/`; + } else { + let selected = this.selectedItems[0]; + let node_type = selected.node_type; + if (node_type === 'attract_task' && this.contextType !== 'tasks') { + return `/attract/${projectUrl}/${this.contextType}/with-task/${selected._id}`; + } else { + return `/attract/${projectUrl}/${this.contextType}/${selected._id}`; + } + } + + } + }, + watch: { + selectedItems(newValue) { + function equals(arrA, arrB) { + if (arrA.length === arrB.length) { + return arrA.every(it => arrB.includes(it)) && + arrB.every(it => arrA.includes(it)) + } + return false; + } + let newSelectedIds = newValue.map(item => item._id); + // They will be equal for instance when we pop browser history + if (equals(newSelectedIds, this.selectedIds)) return; + + this.selectedIds = newSelectedIds; + } + }, + methods: { + onSelectItemsChanged(selectedItems) { + this.selectedItems = selectedItems; + }, + onEditingObjects(isEditing) { + this.isEditing = !!isEditing; + }, + onTableInitialized() { + this.isTableInited = true; + }, + canChangeSelectionCB() { + if(this.isEditing) { + let retval = confirm("You have unsaved data. Do you want to discard it?"); + return retval; + } + return true + }, + /** + * @override BrowserHistoryState + */ + stateSaveMode(newState, oldState) { + if (!this.isTableInited) { + return StateSaveMode.IGNORE; + } + + if (!oldState) { + // Initial state. Replace what we have so we can go back to this state + return StateSaveMode.REPLACE; + } + if (newState.selectedIds.length > 1 && oldState.selectedIds.length > 1) { + // To not spam history when multiselecting items + return StateSaveMode.REPLACE; + } + return StateSaveMode.PUSH; + }, + /** + * @override BrowserHistoryState + */ + applyHistoryState(newState) { + this.selectedIds = newState.selectedIds || this.selectedIds; + } + }, +}); diff --git a/src/scripts/js/es6/common/vuecomponents/EventBus.js b/src/scripts/js/es6/common/vuecomponents/EventBus.js deleted file mode 100644 index 63031c4..0000000 --- a/src/scripts/js/es6/common/vuecomponents/EventBus.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Temporary (cross my fingers) hack to restore "active" item in table - */ -export const Events = { - DESELECT_ITEMS: 'deselect_items', -} -export const EventBus = new Vue(); diff --git a/src/scripts/js/es6/common/vuecomponents/activities/Activities.js b/src/scripts/js/es6/common/vuecomponents/activities/Activities.js new file mode 100644 index 0000000..85ea4ec --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/activities/Activities.js @@ -0,0 +1,51 @@ +import './Activity' + +const TEMPLATE =` +
+
    + +
+
+`; + +Vue.component('attract-activities', { + template: TEMPLATE, + props: { + objectId: String, + outdated: { + type: Boolean, + default: true + } + }, + data() { + return { + activities: [], + } + }, + watch: { + objectId() { + this.fetchActivities(); + }, + outdated(isOutDated) { + if(isOutDated) { + this.fetchActivities(); + } + } + }, + created() { + this.fetchActivities() + }, + methods: { + fetchActivities() { + pillar.api.thenGetNodeActivities(this.objectId) + .then(it => { + this.activities = it['_items']; + this.$emit('activities-updated'); + }); + } + }, +}); diff --git a/src/scripts/js/es6/common/vuecomponents/activities/Activity.js b/src/scripts/js/es6/common/vuecomponents/activities/Activity.js new file mode 100644 index 0000000..2d21a15 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/activities/Activity.js @@ -0,0 +1,29 @@ +const TEMPLATE =` +
  • + + + {{ prettyCreated }} + + + {{ activity.actor_user.full_name }} + + + {{ activity.verb }} + +
  • +`; + +Vue.component('attract-activity', { + template: TEMPLATE, + props: { + activity: Object, + }, + computed: { + prettyCreated() { + return pillar.utils.prettyDate(this.activity._created, true); + } + }, +}); diff --git a/src/scripts/js/es6/common/vuecomponents/assetstable/Table.js b/src/scripts/js/es6/common/vuecomponents/assetstable/Table.js index cee5073..5847e10 100644 --- a/src/scripts/js/es6/common/vuecomponents/assetstable/Table.js +++ b/src/scripts/js/es6/common/vuecomponents/assetstable/Table.js @@ -23,13 +23,16 @@ let TableActions = { } }, methods: { - createNewAsset() { - asset_create(ProjectUtils.projectUrl()); + createNewAsset(event) { + thenCreateAsset(ProjectUtils.projectUrl()) + .then((asset) => { + this.$emit('item-clicked', event, asset._id); + }); } }, } -Vue.component('attract-assets-table', { +let AssetsTable = Vue.component('attract-assets-table', { extends: PillarTable, columnFactory: AssetColumnFactory, rowsSource: AssetRowsSource, @@ -38,3 +41,5 @@ Vue.component('attract-assets-table', { 'pillar-table-row-filter': RowFilter, } }); + +export { AssetsTable }; diff --git a/src/scripts/js/es6/common/vuecomponents/assetstable/rows/AssetRow.js b/src/scripts/js/es6/common/vuecomponents/assetstable/rows/AssetRow.js index cf64bc1..26e8a13 100644 --- a/src/scripts/js/es6/common/vuecomponents/assetstable/rows/AssetRow.js +++ b/src/scripts/js/es6/common/vuecomponents/assetstable/rows/AssetRow.js @@ -1,5 +1,6 @@ import { AttractRowBase } from '../../attracttable/rows/AttractRowBase' import { TaskEventListener } from '../../attracttable/rows/TaskEventListener'; +import { TaskRow } from '../../taskstable/rows/TaskRow' class AssetRow extends AttractRowBase { constructor(asset) { @@ -7,12 +8,15 @@ class AssetRow extends AttractRowBase { this.tasks = []; } - thenInit() { + _thenInitImpl() { return attract.api.thenGetTasks(this.getId()) .then((response) => { - this.tasks = response._items; + this.tasks = response._items.map(it => new TaskRow(it)); this.registerTaskEventListeners(); - this.isInitialized = true; + + return Promise.all( + this.tasks.map(t => t.thenInit()) + ); }); } @@ -22,9 +26,13 @@ class AssetRow extends AttractRowBase { getTasksOfType(taskType) { return this.tasks.filter((t) => { - return t.properties.task_type === taskType; + return t.getProperties().task_type === taskType; }) } + + getChildObjects() { + return this.tasks; + } } export { AssetRow } diff --git a/src/scripts/js/es6/common/vuecomponents/assetstable/rows/AssetRowsSource.js b/src/scripts/js/es6/common/vuecomponents/assetstable/rows/AssetRowsSource.js index 53f109b..1a94caa 100644 --- a/src/scripts/js/es6/common/vuecomponents/assetstable/rows/AssetRowsSource.js +++ b/src/scripts/js/es6/common/vuecomponents/assetstable/rows/AssetRowsSource.js @@ -6,7 +6,7 @@ class AssetRowsSource extends AttractRowsSourceBase { super(projectId, 'attract_asset', AssetRow); } - thenInit() { + thenFetchObjects() { return attract.api.thenGetProjectAssets(this.projectId) .then((result) => { let assets = result._items; diff --git a/src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellRowObject.js b/src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellRowObject.js index c0b4537..15b2451 100644 --- a/src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellRowObject.js +++ b/src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellRowObject.js @@ -3,7 +3,7 @@ let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault; const TEMPLATE =`
    {{ cellValue }} @@ -19,18 +19,16 @@ let CellRowObject = Vue.component('pillar-cell-row-object', { let project_url = ProjectUtils.projectUrl(); let item_type = this.itemType(); return `/attract/${project_url}/${item_type}s/${this.rowObject.getId()}`; - }, - embededLink() { - return this.cellLink; } }, methods: { - onClick() { - item_open(this.rowObject.getId(), this.itemType(), true, ProjectUtils.projectUrl()); - }, itemType() { let node_type = this.rowObject.underlyingObject.node_type; return node_type.replace('attract_', ''); // eg. attract_task to tasks + }, + ignoreDefault(event) { + // Don't follow link, let the event bubble and the row handles it + event.preventDefault(); } }, }); diff --git a/src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellTasks.js b/src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellTasks.js index 05f2df0..50f5b4b 100644 --- a/src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellTasks.js +++ b/src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellTasks.js @@ -1,23 +1,22 @@ -import {EventBus, Events} from '../../../EventBus' let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault; +import './CellTasksLink' const TEMPLATE =` `; @@ -35,46 +34,19 @@ let CellTasks = Vue.component('attract-cell-tasks', { return attract.auth.AttractAuth.canUserCreateTask(projectId); } return false; + }, + itemType() { + let node_type = this.rowObject.underlyingObject.node_type; + return node_type.replace('attract_', '') + 's'; // eg. attract_asset to assets } }, - data() { - return { - selected_task_id: null - } - }, - created() { - EventBus.$on(Events.DESELECT_ITEMS, this.deselectTask); - }, - beforeDestroy() { - EventBus.$off(Events.DESELECT_ITEMS, this.deselectTask); - }, methods: { - taskClass(task) { - let classes = {'active': this.selected_task_id === task._id}; - classes[`task status-${task.properties.status}`] = true; - return classes; - }, - taskLink(task) { - let project_url = ProjectUtils.projectUrl(); - let node_type = this.rowObject.underlyingObject.node_type; - let item_type = node_type.replace('attract_', '') + 's'; // eg. attract_asset to assets - return `/attract/${project_url}/${item_type}/with-task/${task._id}`; - }, - taskTitle(task) { - let status = (task.properties.status || '').replace('_', ' '); - return `Task: ${task.name}\nStatus: ${status}` - }, - onTaskClicked(task) { - task_open(task._id, ProjectUtils.projectUrl()); - EventBus.$emit(Events.DESELECT_ITEMS); - this.selected_task_id = task._id; - }, onAddTask(event) { - task_create(this.rowObject.getId(), this.column.taskType); + thenCreateTask(this.rowObject.getId(), this.column.taskType) + .then((task) => { + this.$emit('item-clicked', event, task._id); + }); }, - deselectTask() { - this.selected_task_id = null; - } }, }); diff --git a/src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellTasksLink.js b/src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellTasksLink.js new file mode 100644 index 0000000..5c9dde4 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/attracttable/cells/renderer/CellTasksLink.js @@ -0,0 +1,33 @@ +const TEMPLATE =` + +`; + +let CellTasksLink = Vue.component('attract-cell-task-link', { + template: TEMPLATE, + props: { + task: Object, + itemType: String, + }, + computed: { + taskClass() { + let classes = {'active': this.task.isSelected}; + classes[`status-${this.task.getProperties().status}`] = true; + return classes; + }, + taskLink() { + let project_url = ProjectUtils.projectUrl(); + return `/attract/${project_url}/${this.itemType}/with-task/${this.task.getId()}`; + }, + taskTitle() { + let status = (this.task.getProperties().status || '').replace('_', ' '); + return `Task: ${this.task.getName()}\nStatus: ${status}` + }, + }, +}); + +export { CellTasksLink } diff --git a/src/scripts/js/es6/common/vuecomponents/attracttable/columns/TaskDueDate.js b/src/scripts/js/es6/common/vuecomponents/attracttable/columns/TaskDueDate.js index c56bd06..c08c3ed 100644 --- a/src/scripts/js/es6/common/vuecomponents/attracttable/columns/TaskDueDate.js +++ b/src/scripts/js/es6/common/vuecomponents/attracttable/columns/TaskDueDate.js @@ -64,7 +64,7 @@ export class FirstTaskDueDate extends DueDate { super('First Due Date', 'first-duedate'); } getRawCellValue(rowObject) { - let tasks = rowObject.tasks || []; + let tasks = (rowObject.tasks || []).map(task => task.underlyingObject); return tasks.reduce(firstDate, undefined) || ''; } } @@ -74,7 +74,7 @@ export class LastTaskDueDate extends DueDate { super('Last Due Date', 'last-duedate'); } getRawCellValue(rowObject) { - let tasks = rowObject.tasks || []; + let tasks = (rowObject.tasks || []).map(task => task.underlyingObject); return tasks.reduce(lastDate, undefined) || ''; } } @@ -84,7 +84,7 @@ export class NextTaskDueDate extends DueDate { super('Next Due Date', 'next-duedate'); } getRawCellValue(rowObject) { - let tasks = rowObject.tasks || []; + let tasks = (rowObject.tasks || []).map(task => task.underlyingObject); return tasks.reduce(nextDate, undefined) || ''; } } diff --git a/src/scripts/js/es6/common/vuecomponents/attracttable/rows/AttractRowBase.js b/src/scripts/js/es6/common/vuecomponents/attracttable/rows/AttractRowBase.js index 02ea156..ce45608 100644 --- a/src/scripts/js/es6/common/vuecomponents/attracttable/rows/AttractRowBase.js +++ b/src/scripts/js/es6/common/vuecomponents/attracttable/rows/AttractRowBase.js @@ -6,8 +6,8 @@ class AttractRowBase extends RowBase { pillar.events.Nodes.onUpdated(this.getId(), this.onRowUpdated.bind(this)); } - onRowUpdated(event, updatedObj) { - this.underlyingObject = updatedObj; + onRowUpdated(event) { + this.underlyingObject = event.detail; } } diff --git a/src/scripts/js/es6/common/vuecomponents/attracttable/rows/AttractRowsSourceBase.js b/src/scripts/js/es6/common/vuecomponents/attracttable/rows/AttractRowsSourceBase.js index 315003e..434c6c9 100644 --- a/src/scripts/js/es6/common/vuecomponents/attracttable/rows/AttractRowsSourceBase.js +++ b/src/scripts/js/es6/common/vuecomponents/attracttable/rows/AttractRowsSourceBase.js @@ -9,7 +9,6 @@ class AttractRowsSourceBase extends RowObjectsSourceBase { createRow(node) { let row = new this.rowClass(node); - row.thenInit(); this.registerListeners(row); return row; } @@ -23,14 +22,15 @@ class AttractRowsSourceBase extends RowObjectsSourceBase { pillar.events.Nodes.onDeleted(rowObject.getId(), this.onNodeDeleted.bind(this)); } - onNodeDeleted(event, nodeId) { + onNodeDeleted(event) { this.rowObjects = this.rowObjects.filter((rowObj) => { - return rowObj.getId() !== nodeId; + return rowObj.getId() !== event.detail; }); } - onNodeCreated(event, node) { - let rowObj = this.createRow(node); + onNodeCreated(event) { + let rowObj = this.createRow(event.detail); + rowObj.thenInit(); this.rowObjects = this.rowObjects.concat(rowObj); } } diff --git a/src/scripts/js/es6/common/vuecomponents/attracttable/rows/TaskEventListener.js b/src/scripts/js/es6/common/vuecomponents/attracttable/rows/TaskEventListener.js index 24b0cd3..a79dd6e 100644 --- a/src/scripts/js/es6/common/vuecomponents/attracttable/rows/TaskEventListener.js +++ b/src/scripts/js/es6/common/vuecomponents/attracttable/rows/TaskEventListener.js @@ -1,3 +1,4 @@ +import { TaskRow } from '../../taskstable/rows/TaskRow' /** * Helper class that listens to events triggered when a RowObject task is updated/created/deleted and keep the tasks * array of a RowObject up to date accordingly. @@ -14,24 +15,29 @@ export class TaskEventListener { } registerEventListeners(task) { - pillar.events.Nodes.onUpdated(task._id, this.onTaskUpdated.bind(this)); - pillar.events.Nodes.onDeleted(task._id, this.onTaskDeleted.bind(this)); + pillar.events.Nodes.onUpdated(task.getId(), this.onTaskUpdated.bind(this)); + pillar.events.Nodes.onDeleted(task.getId(), this.onTaskDeleted.bind(this)); } - onTaskCreated(event, newTask) { - this.registerEventListeners(newTask); - this.rowObject.tasks = this.rowObject.tasks.concat(newTask); + onTaskCreated(event) { + let task = new TaskRow(event.detail); + this.registerEventListeners(task); + this.rowObject.tasks = this.rowObject.tasks.concat(task); } - onTaskUpdated(event, updatedTask) { - this.rowObject.tasks = this.rowObject.tasks.map((t) => { - return t._id === updatedTask._id ? updatedTask : t; - }); + onTaskUpdated(event) { + let updatedTask = event.detail; + for (const task of this.rowObject.tasks) { + if (task.getId() === updatedTask._id) { + task.underlyingObject = updatedTask; + break; + } + } } - onTaskDeleted(event, taskId) { + onTaskDeleted(event) { this.rowObject.tasks = this.rowObject.tasks.filter((t) => { - return t._id !== taskId; + return t.getId() !== event.detail; }); } } diff --git a/src/scripts/js/es6/common/vuecomponents/detailedview/Empty.js b/src/scripts/js/es6/common/vuecomponents/detailedview/Empty.js new file mode 100644 index 0000000..09821f9 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/detailedview/Empty.js @@ -0,0 +1,9 @@ +const TEMPLATE =` +
    Select Something
    +`; + +let Empty = Vue.component('attract-editor-empty', { + template: TEMPLATE, +}); + +export {Empty} diff --git a/src/scripts/js/es6/common/vuecomponents/detailedview/MultipleTypes.js b/src/scripts/js/es6/common/vuecomponents/detailedview/MultipleTypes.js new file mode 100644 index 0000000..870c423 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/detailedview/MultipleTypes.js @@ -0,0 +1,11 @@ +const TEMPLATE =` +
    + Objects of different types selected +
    +`; + +let MultipleTypes = Vue.component('attract-editor-multiple-types', { + template: TEMPLATE, +}); + +export {MultipleTypes} diff --git a/src/scripts/js/es6/common/vuecomponents/detailedview/Viewer.js b/src/scripts/js/es6/common/vuecomponents/detailedview/Viewer.js new file mode 100644 index 0000000..8490074 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/detailedview/Viewer.js @@ -0,0 +1,114 @@ +import { Empty } from './Empty' +import { MultipleTypes } from './MultipleTypes' +import { AssetEditor } from '../editor/AssetEditor' +import { TaskEditor } from '../editor/TaskEditor' +import { ShotEditor } from '../editor/ShotEditor' +import '../activities/Activities' + +const TEMPLATE =` +
    +
    + + {{ headerText }} + + +
    + + + +
    +`; + +Vue.component('attract-detailed-view', { + template: TEMPLATE, + props: { + items: Array, + project: Object, + contextType: String + }, + data() { + return { + isActivitiesOutdated: true + } + }, + data() { + return { + isActivitiesOutdated: true + } + }, + computed: { + headerText() { + switch(this.items.length) { + case 0: return 'Details'; + case 1: return `${this.itemsTypeFormated} Details` + default: return `${this.itemsTypeFormated} Details (${this.items.length})`; + } + }, + itemsType() { + let itemsType = this.items.reduce((prevType, it) => { + if(prevType) { + return prevType === it.node_type ? prevType : 'multiple_types'; + } + return it.node_type; + }, null); + + return itemsType || 'empty'; + }, + itemsTypeFormated() { + return this.itemsType.replace('attract_', '').replace('multiple_types', ''); + }, + editorType() { + if(!this.project) { + return Empty.options.name; + } + switch(this.itemsType) { + case 'attract_asset': return AssetEditor.options.name; + case 'attract_shot': return ShotEditor.options.name; + case 'attract_task': return TaskEditor.options.name; + case 'multiple_types': return MultipleTypes.options.name; + case 'empty': return Empty.options.name; + default: + console.log('No editor for:', this.itemsType); + return Empty.options.name; + } + }, + isMultiItemsView() { + return this.items.length > 1; + }, + isSingleItemView() { + return this.items.length === 1; + }, + singleObjectId() { + return this.isSingleItemView ? this.items[0]._id : ''; + }, + }, + methods: { + activitiesIsOutdated() { + this.isActivitiesOutdated = true; + }, + activitiesIsUpToDate() { + this.isActivitiesOutdated = false + } + }, +}); + diff --git a/src/scripts/js/es6/common/vuecomponents/editor/AssetEditor.js b/src/scripts/js/es6/common/vuecomponents/editor/AssetEditor.js new file mode 100644 index 0000000..9a30c08 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/editor/AssetEditor.js @@ -0,0 +1,113 @@ +import './base/TextArea' +import './base/ConclusiveMark' +import {EditorBase, BaseProps} from './base/EditorBase' + +const TEMPLATE =` +
    +
    + + + +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    +
    + +
    +
    +`; + + +const AllProps = Object.freeze({ + ...BaseProps, + PROP_NOTES: 'properties.notes', +}); + +let ALL_PROPERTIES = []; + +for (const key in AllProps) { + ALL_PROPERTIES.push(AllProps[key]); +} + +let AssetEditor = Vue.component('attract-editor-asset', { + template: TEMPLATE, + extends: EditorBase, + data() { + return { + multiEditEngine: this.createEditorEngine(ALL_PROPERTIES), + } + }, + watch: { + items() { + this.multiEditEngine = this.createEditorEngine(ALL_PROPERTIES); + }, + }, + computed: { + notesProp() { + return this.multiEditEngine.getProperty(AllProps.PROP_NOTES); + }, + }, +}); + +export {AssetEditor} diff --git a/src/scripts/js/es6/common/vuecomponents/editor/ShotEditor.js b/src/scripts/js/es6/common/vuecomponents/editor/ShotEditor.js new file mode 100644 index 0000000..150be67 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/editor/ShotEditor.js @@ -0,0 +1,246 @@ +import './base/TextArea' +import './base/ConclusiveMark' +import {EditorBase, BaseProps} from './base/EditorBase' + +const TEMPLATE =` +
    +
    +
    + + + {{ valueOrNA(nameProp.value) }} + + +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    +
    + + Last Update +
    +
    + +
    + {{ prettyDate(createdProp.value) }} +
    +
    +
    +
    +
    + + Used in Edit +
    +
    + {{ formatBool(usedInEditProp.value) }} +
    +
    +
    +
    + + Cut-in +
    +
    + at frame {{ valueOrNA(cutInTimelineProp.value) }} +
    +
    +
    +
    + + Trim Start +
    +
    + {{ valueOrNA(trimStartProp.value) }} frames +
    +
    +
    +
    + + Trim End +
    +
    + {{ valueOrNA(trimEndProp.value) }} frames +
    +
    +
    +
    + + Duration in Edit +
    +
    + {{ valueOrNA(durationInEditProp.value) }} frames +
    +
    +
    +
    +
    +
    +`; + + +const AllProps = Object.freeze({ + ...BaseProps, + UPDATED: '_updated', + CREATED: '_created', + NOTES: 'properties.notes', + USED_IN_EDIT: 'properties.used_in_edit', + CUT_IN_TIMELINE: 'properties.cut_in_timeline_in_frames', + TRIM_START: 'properties.trim_start_in_frames', + TRIM_END: 'properties.trim_end_in_frames', + DURATION_IN_EDIT: 'properties.duration_in_edit_in_frames', +}); + +let ALL_PROPERTIES = []; + +for (const key in AllProps) { + ALL_PROPERTIES.push(AllProps[key]); +} + +let ShotEditor = Vue.component('attract-editor-shot', { + template: TEMPLATE, + extends: EditorBase, + data() { + return { + multiEditEngine: this.createEditorEngine(ALL_PROPERTIES), + } + }, + watch: { + items() { + this.multiEditEngine = this.createEditorEngine(ALL_PROPERTIES); + }, + }, + computed: { + notesProp() { + return this.multiEditEngine.getProperty(AllProps.NOTES); + }, + updatedProp() { + return this.multiEditEngine.getProperty(AllProps.UPDATED); + }, + createdProp() { + return this.multiEditEngine.getProperty(AllProps.CREATED); + }, + usedInEditProp() { + return this.multiEditEngine.getProperty(AllProps.USED_IN_EDIT); + }, + cutInTimelineProp() { + return this.multiEditEngine.getProperty(AllProps.CUT_IN_TIMELINE); + }, + trimStartProp() { + return this.multiEditEngine.getProperty(AllProps.TRIM_START); + }, + trimEndProp() { + return this.multiEditEngine.getProperty(AllProps.TRIM_END); + }, + durationInEditProp() { + return this.multiEditEngine.getProperty(AllProps.DURATION_IN_EDIT); + }, + }, + methods: { + valueOrNA(value) { + return value ? value : 'N/A' + }, + formatBool(value) { + switch (value) { + case true: return 'Yes'; + case false: return 'No'; + default: return 'N/A'; + } + }, + prettyDate(value) { + if(value) { + return pillar.utils.prettyDate(value, true); + } + return 'N/A'; + } + }, +}); + +export {ShotEditor} diff --git a/src/scripts/js/es6/common/vuecomponents/editor/TaskEditor.js b/src/scripts/js/es6/common/vuecomponents/editor/TaskEditor.js new file mode 100644 index 0000000..0bdfd16 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/editor/TaskEditor.js @@ -0,0 +1,244 @@ +import './base/TextArea' +import './base/ConclusiveMark' +import './base/Select2' +import './base/DatePicker' +import {EditorBase, BaseProps} from './base/EditorBase' + +const TEMPLATE =` +
    + +
    + + +
    +
    +
    + + +
    +
    + + + +
    +
    +
    +
    + + + + + +
    +
    + + + +
    +
    +
    + +
    +
    +`; + + +const AllProps = Object.freeze({ + ...BaseProps, + PARENT: 'parent', + TASK_TYPE: 'properties.task_type', + DUE_DATE: 'properties.due_date', + ASSIGNED_TO: 'properties.assigned_to.users', + SHORT_CODE: 'properties.short_code', +}); + +let ALL_PROPERTIES = []; + +for (const key in AllProps) { + ALL_PROPERTIES.push(AllProps[key]); +} + +let TaskEditor = Vue.component('attract-editor-task', { + template: TEMPLATE, + extends: EditorBase, + props: { + contextType: String + }, + data() { + return { + multiEditEngine: this.createEditorEngine(ALL_PROPERTIES), + users: [], + } + }, + created() { + this.fetchUsers() + }, + watch: { + items() { + this.multiEditEngine = this.createEditorEngine(ALL_PROPERTIES); + }, + }, + computed: { + parentProp() { + return this.multiEditEngine.getProperty(AllProps.PARENT); + }, + taskTypeProp() { + return this.multiEditEngine.getProperty(AllProps.TASK_TYPE); + }, + allowedTaskTypes() { + let shot_task_types = this.project.extension_props.attract.task_types.attract_shot; + + return ['generic', ...shot_task_types]; + }, + canChangeTaskType() { + return this.parentProp.isConclusive() && !this.parentProp.value; + }, + allowedTaskTypesPair() { + function format(status) { + // hair_sim => Hair sim + let first = status[0].toUpperCase(); + let last = status.substr(1).replace('_', ' '); + return `${first}${last}`; + } + + return this.allowedTaskTypes.map(it => { + return { + id: it, + text: format(it) + } + }); + }, + dueDateProp() { + return this.multiEditEngine.getProperty(AllProps.DUE_DATE); + }, + assignedToProp() { + return this.multiEditEngine.getProperty(AllProps.ASSIGNED_TO); + }, + }, + methods: { + fetchUsers() { + pillar.api.thenGetProjectUsers(this.project._id) + .then(users => { + this.users = users._items.map(it =>{ + return { + id: it._id, + text: it.full_name, + }; + }); + }); + }, + deleteTasks() { + this.items.map(pillar.api.thenDeleteNode); + } + }, +}); + +export {TaskEditor} diff --git a/src/scripts/js/es6/common/vuecomponents/editor/base/ConclusiveMark.js b/src/scripts/js/es6/common/vuecomponents/editor/base/ConclusiveMark.js new file mode 100644 index 0000000..6675b7f --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/editor/base/ConclusiveMark.js @@ -0,0 +1,42 @@ + +const TEMPLATE =` + +`; + +Vue.component('attract-property-conslusive-mark', { + template: TEMPLATE, + props: { + prop: Object, + }, + computed: { + classes() { + return this.prop.isConclusive() ? 'pi-link' : 'pi-unlink'; + }, + toolTip() { + if (this.prop.isConclusive()) { + return 'All objects has the same value' + } else { + let values = this.prop.getOriginalValues(); + let toolTip = 'Objects has diverging values:'; + let i = 0; + for (const it of values) { + if (i === 5) { + toolTip += `\n...`; + break; + } + toolTip += `\n${++i}: ${this.shorten(it)}`; + } + return toolTip; + } + }, + }, + methods: { + shorten(value) { + let s = `${value}`; + return s.length < 30 ? s : `${s.substr(0, 27)}...` + } + }, +}); diff --git a/src/scripts/js/es6/common/vuecomponents/editor/base/DatePicker.js b/src/scripts/js/es6/common/vuecomponents/editor/base/DatePicker.js new file mode 100644 index 0000000..7b47fe8 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/editor/base/DatePicker.js @@ -0,0 +1,50 @@ +/** + * Wrapper around Pikaday + */ +let TEMPLATE = ` + +`; + +Vue.component('attract-date-picker', { + props: { + value: String + }, + template: TEMPLATE, + data() { + return { + picker: null // inited in this.initDatePicker() + } + }, + mounted: function () { + this.$nextTick(this.initDatePicker); + }, + watch: { + value(newValue, oldValue) { + this.picker.setDate(newValue); + } + }, + methods: { + initDatePicker() { + let vm = this; + this.picker = new Pikaday( + { + field: this.$refs.datepicker, + firstDay: 1, + showTime: false, + use24hour: true, + format: 'dddd D, MMMM YYYY', + disableWeekends: true, + timeLabel: 'Time: ', + autoClose: true, + incrementMinuteBy: 15, + yearRange: [new Date().getFullYear(),new Date().getFullYear() + 5], + onSelect: function(date) { + // This is a bit ugly. Can we solve this in a better way? + let dateAsConfigedInEve = this.getMoment().format('ddd, DD MMM YYYY [00:00:00 GMT]') + vm.$emit('input', dateAsConfigedInEve); + } + }); + this.picker.setDate(this.value); + } + }, + }) diff --git a/src/scripts/js/es6/common/vuecomponents/editor/base/EditorBase.js b/src/scripts/js/es6/common/vuecomponents/editor/base/EditorBase.js new file mode 100644 index 0000000..29532cc --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/editor/base/EditorBase.js @@ -0,0 +1,128 @@ +import {MultiEditEngine} from './MultiEditEngine' +let UnitOfWorkTracker = pillar.vuecomponents.mixins.UnitOfWorkTracker; + + +const BaseProps = Object.freeze({ + NAME: 'name', + DESCRIPTION: 'description', + STATUS: 'properties.status', + NODE_TYPE: 'node_type', +}); + +let ALL_BASE_PROPERTIES = []; + +for (const key in BaseProps) { + ALL_BASE_PROPERTIES.push(BaseProps[key]); +} + +let EditorBase = Vue.component('attract-editor-Base', { + mixins: [UnitOfWorkTracker], + props: { + items: Array, + project: Object, + }, + data() { + return { + multiEditEngine: this.createEditorEngine(ALL_BASE_PROPERTIES), + } + }, + watch: { + items() { + this.multiEditEngine = this.createEditorEngine(ALL_BASE_PROPERTIES); + }, + statusPropEdited(isEdited) { + if(isEdited && this.items.length === 1) { + // Auto save on status is convenient, but could lead to head ache in multi edit. + this.save(); + } + }, + isEdited(isEdited) { + this.$emit('objects-are-edited', isEdited); + } + }, + computed: { + isMultpleItems() { + return this.items.length > 1; + }, + nodeTypeProp() { + return this.multiEditEngine.getProperty(BaseProps.NODE_TYPE); + }, + allowedStatuses() { + let tmp = this.project.node_types.filter((it) => it.name === this.nodeTypeProp.value); + if(tmp.length === 1) { + let nodeTypeDefinition = tmp[0]; + return nodeTypeDefinition.dyn_schema.status.allowed; + } + console.log('Failed to find allowed statused for node type:', this.nodeTypeProp.value); + return []; + }, + allowedStatusesPair() { + function format(status) { + // in_progress => In progress + let first = status[0].toUpperCase(); + let last = status.substr(1).replace('_', ' '); + return `${first}${last}`; + } + return this.allowedStatuses.map(it => { + return { + id: it, + text: format(it) + } + }); + }, + nameProp() { + return this.multiEditEngine.getProperty(BaseProps.NAME); + }, + descriptionProp() { + return this.multiEditEngine.getProperty(BaseProps.DESCRIPTION); + }, + statusProp() { + return this.multiEditEngine.getProperty(BaseProps.STATUS); + }, + statusPropEdited() { + return this.statusProp.isEdited(); + }, + editorClasses() { + let status = this.statusProp.isConclusive() ? this.statusProp.value : 'inconclusive'; + let classes = {} + classes[`status-${status}`] = true; + return classes; + }, + isEdited() { + return this.multiEditEngine.isEdited(); + }, + canEdit() { + let canUseAttract = attract.auth.AttractAuth.canUserCanUseAttract(ProjectUtils.projectId()); + return canUseAttract && this.multiEditEngine.allowedToEdit(); + } + }, + methods: { + classesForProperty(prop) { + return { + 'inconclusive': !prop.isConclusive(), + 'edited': prop.isEdited(), + } + }, + conclusiveIcon(prop) { + return prop.isConclusive() ? 'pi-link' : 'pi-unlink'; + }, + createEditorEngine(props) { + return new MultiEditEngine(this.items, ...props); + }, + save() { + let toBeSaved = this.multiEditEngine.createUpdatedItems(); + let promises = toBeSaved.map(pillar.api.thenUpdateNode); + + this.unitOfWork( + Promise.all(promises) + .then(() => { + this.$emit('saved-items'); + }) + .catch((err) => {toastr.error(pillar.utils.messageFromError(err), 'Save Failed')}) + ); + }, + }, +}); + +export {EditorBase, BaseProps} + diff --git a/src/scripts/js/es6/common/vuecomponents/editor/base/MultiEditEngine.js b/src/scripts/js/es6/common/vuecomponents/editor/base/MultiEditEngine.js new file mode 100644 index 0000000..850fa1b --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/editor/base/MultiEditEngine.js @@ -0,0 +1,323 @@ +/** + * MultiEditEngine + * n-MultiProperty + * 1-PropertyCB + * + * Class to edit multiple objects at the same time. + * It keeps track of what object properties has been edited, which properties that still diffs, + * + * @example + * let myObjects = [{ + * 'name': 'Bob', + * 'personal': { + * 'hobby': 'Fishing' + * } + * },{ + * 'name': 'Greg', + * 'personal': { + * 'hobby': 'Movies' + * } + * }] + * // Create engine with list of objects to edit, and the properties we want to be able to edit. + * let engine = new MultiEditEngine(myObjects,'name', 'personal.hobby'); + * + * engine.getProperty('personal.hobby').isConclusive(); // false since one is 'Fishing' and one 'Movies' + * engine.getProperty('personal.hobby').isEdited(); // false + * + * engine.getProperty('personal.hobby').value = 'Fishing'; + * engine.getProperty('personal.hobby').isConclusive(); // true + * engine.getProperty('personal.hobby').isEdited(); // true + * engine.getProperty('personal.hobby').getOriginalValues(); // A set with the original values 'Fishing' and 'Movies' + * + * engine.getProperty('name').isConclusive(); // false since one is 'Bob' and one is 'Greg' + * engine.getProperty('personal.hobby').isEdited(); // false since this property has not been edited + * + * let updatedObjects = engine.createUpdatedItems(); + * // updatedObjects is now: [{'name': 'Greg', 'hobby': 'Fishing'}] + * // myObjects is still unchanged. + */ + +function areEqual(valA, valB) { + if(Array.isArray(valB) && Array.isArray(valB)) { + if(valA.length === valB.length) { + for (let i = 0; i < valA.length; i++) { + if(!areEqual(valA[i], valB[i])) return false; + } + return true; + } + return false; + } else { + return valA === valB; + } +} + +class UniqueValues { + constructor() { + this._values = new Set(); + } + + get size() { + return this._values.size; + } + + /** + * + * @param {*} valueCandidate + */ + addIfUnique(valueCandidate) { + if (Array.isArray(valueCandidate)) { + for (const uniqueValue of this._values) { + if(!Array.isArray(uniqueValue)) continue; + if(areEqual(valueCandidate, uniqueValue)) { + // not a new value. Don't add + return; + } + } + this._values.add(valueCandidate); + } else { + this._values.add(valueCandidate); + } + } + + getValueOrInconclusive() { + if (this.size === 1) { + return this._values.values().next().value; + } + return INCONCLUSIVE; + } + + getValues() { + return new Set([...this._values]); + } + + _areArraysEqual(arrA, arrB) { + if(arrA.size === arrB.size) { + for (let i = 0; i < arrA.length; i++) { + if(arrA[i] !== arrB[i]) return false; + } + return true; + } + return false; + } + } + + class PropertyCB { + constructor(propertyPath) { + this.name = propertyPath; + this._propertyPath = propertyPath.split('.'); + this._propertyKey = this._propertyPath.pop(); + } + + /** + * Get the property from the item + * @param {Object} item + * @returns {*} Property value + */ + getValue(item) { + let tmp = item; + for (const key of this._propertyPath) { + tmp = (tmp || {})[key] + } + return (tmp || {})[this._propertyKey]; + } + + /** + * Assign a new value to the property + * @param {Object} item + * @param {*} newValue + */ + setValue(item, newValue) { + let tmp = item; + for (const key of this._propertyPath) { + tmp[key] = tmp[key] || {}; + tmp = tmp[key]; + } + tmp[this._propertyKey] = newValue; + } + } + +// Dummy object to indicate that a property is unedited. +const NOT_SET = Symbol('Not Set'); +const INCONCLUSIVE = Symbol('Inconclusive'); + +class MultiProperty { + /** + * + * @param {String} propPath Dot separeted path to property + */ + constructor(propPath) { + this.propCB = new PropertyCB(propPath);; + this.originalValues = new UniqueValues(); + this.newValue = NOT_SET; + } + + get value() { + return this.newValue !== NOT_SET ? + this.newValue : + this._getOriginalValue(); + } + + set value(newValue) { + if (areEqual(newValue, this._getOriginalValue())) { + this.reset(); + } else { + this.newValue = newValue; + } + } + + /** + * Returns a Set with all values the different object has for the property. + * @returns {Set} + */ + getOriginalValues() { + return this.originalValues.getValues(); + } + + /** + * Ture if property has been edited. + * @returns {Boolean} + */ + isEdited() { + return this.newValue !== NOT_SET; + } + + /** + * Undo changes to property. + */ + reset() { + this.newValue = NOT_SET; + } + + /** + * True if all objects has the same value for this property. + * @returns {Boolean} + */ + isConclusive() { + if (this.newValue !== NOT_SET) { + return true; + } + return this.originalValues.size == 1; + } + + _applyNewValue(item) { + if (this.isEdited()) { + this.propCB.setValue(item, this.newValue); + return true; + } + return false; + } + + _getOriginalValue() { + let origVal = this.originalValues.getValueOrInconclusive() + return origVal !== INCONCLUSIVE ? + origVal : undefined; + } + + _addValueFrom(item) { + this.originalValues.addIfUnique(this.propCB.getValue(item)); + } +} + +class MultiEditEngine { + /** + * @param {Array} items An array with the objects to be edited. + * @param {...String} propertyPaths Dot separeted paths to properties. 'name', 'properties.status' + */ + constructor(items, ...propertyPaths) { + this.originalItems = items; + this.properties = this._createMultiproperties(propertyPaths); + } + + /** + * + * @param {String} propName + * @returns {MultiProperty} + */ + getProperty(propName) { + return this.properties[propName]; + } + + /** + * True if all the objects has the same value for all of its monitored properties. + * @returns {Boolean} + */ + isConclusive() { + for (const key in this.properties) { + const prop = this.properties[key]; + if (prop.isConclusive()) { + return true; + } + } + return false; + } + + /** + * True if at least one property has been edited. + * @returns {Boolean} + */ + isEdited() { + for (const key in this.properties) { + const prop = this.properties[key]; + if (prop.isEdited()) { + return true; + } + } + return false; + } + + /** + * Returns an array with copies of the objects with there new values. + * Only the updated objects are included in the array. + * @returns {Array} + */ + createUpdatedItems() { + let updatedItems = []; + for (const it of this.originalItems) { + let itemCopy = JSON.parse(JSON.stringify(it)); + let hasChanged = false; + for (const key in this.properties) { + const prop = this.properties[key]; + hasChanged |= prop._applyNewValue(itemCopy); + } + if(hasChanged) { + updatedItems.push(itemCopy); + } + } + return updatedItems; + } + + /** + * True if all items has 'PUT' in 'allowed_methods'. If object has now 'allowed_methods' we return true + * @returns {Boolean} + */ + allowedToEdit() { + for (const it of this.originalItems) { + if(!it.allowed_methods) continue; + if(!it.allowed_methods.includes('PUT')) { + return false; + } + } + return true; + } + + /** + * Undo all edits on all properties. + */ + reset() { + for (const key in this.properties) { + this.properties[key].reset(); + } + } + + _createMultiproperties(propertyPaths) { + let retval = {} + for (const propPath of propertyPaths) { + let prop = new MultiProperty(propPath); + this.originalItems.forEach(prop._addValueFrom.bind(prop)); + retval[propPath] = prop; + } + return retval; + } +} + +export { MultiEditEngine } diff --git a/src/scripts/js/es6/common/vuecomponents/editor/base/Select2.js b/src/scripts/js/es6/common/vuecomponents/editor/base/Select2.js new file mode 100644 index 0000000..2869ebd --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/editor/base/Select2.js @@ -0,0 +1,56 @@ +/** + * Wrapper around jquery select2. Heavily inspired by: https://vuejs.org/v2/examples/select2.html + */ + +let TEMPLATE = ` +
    + +
    +`; + +Vue.component('attract-select2', { + props: { + options: Object, + value: Array, + disabled: { + type: Boolean, + default: false + } + }, + template: TEMPLATE, + mounted: function () { + this.$nextTick(this.initSelect2); + }, + watch: { + value(value) { + // update value + $(this.$refs.select2) + .val(value) + .trigger('change.select2'); + }, + options(options) { + // update options + $(this.$refs.select2).empty().select2({ data: options }); + } + }, + beforeDestroy() { + $(this.$refs.select2).off().select2('destroy'); + }, + methods: { + initSelect2() { + $(this.$refs.select2) + // init select2 + .select2({ data: this.options }) + .val(this.value) + .trigger('change.select2') + // emit event on change. + .on('change', () => { + this.$emit('input', $(this.$refs.select2).val()); + }); + } + }, + }) diff --git a/src/scripts/js/es6/common/vuecomponents/editor/base/TextArea.js b/src/scripts/js/es6/common/vuecomponents/editor/base/TextArea.js new file mode 100644 index 0000000..7745b75 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/editor/base/TextArea.js @@ -0,0 +1,31 @@ +const TEMPLATE = ` +