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 = ` +