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.
This commit is contained in:
2019-03-13 13:53:40 +01:00
parent f4c7101427
commit bae39ce01d
49 changed files with 1879 additions and 516 deletions

View File

@@ -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 }

View File

@@ -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;

View File

@@ -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 =`
<div class="attract-app">
<div id="col_main">
<component
:is="tableComponentName"
:projectId="projectId"
:selectedIds="selectedIds"
:canChangeSelectionCB="canChangeSelectionCB"
@selectItemsChanged="onSelectItemsChanged"
@isInitialized="onTableInitialized"
/>
</div>
<div class="col-splitter"/>
<attract-detailed-view id="col_right"
:items="selectedItems"
:project="project"
:contextType="contextType"
@objects-are-edited="onEditingObjects"
/>
</div>
`;
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;
}
},
});

View File

@@ -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();

View File

@@ -0,0 +1,51 @@
import './Activity'
const TEMPLATE =`
<div class="d-activity">
<ul>
<attract-activity
v-for="a in activities"
:key="a._id"
:activity="a"
/>
</ul>
</div>
`;
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');
});
}
},
});

View File

@@ -0,0 +1,29 @@
const TEMPLATE =`
<li>
<img class="actor-avatar"
:src="activity.actor_user.gravatar"
/>
<span class="date"
:title="activity._created">
{{ prettyCreated }}
</span>
<span class="actor">
{{ activity.actor_user.full_name }}
</span>
<span class="verb">
{{ activity.verb }}
</span>
</li>
`;
Vue.component('attract-activity', {
template: TEMPLATE,
props: {
activity: Object,
},
computed: {
prettyCreated() {
return pillar.utils.prettyDate(this.activity._created, true);
}
},
});

View File

@@ -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 };

View File

@@ -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 }

View File

@@ -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;

View File

@@ -3,7 +3,7 @@ let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
const TEMPLATE =`
<div>
<a
@click.prevent="onClick()"
@click="ignoreDefault"
:href="cellLink"
>
{{ 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();
}
},
});

View File

@@ -1,23 +1,22 @@
import {EventBus, Events} from '../../../EventBus'
let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
import './CellTasksLink'
const TEMPLATE =`
<div>
<div class="tasks">
<a
<attract-cell-task-link
v-for="t in tasks"
:class="taskClass(t)"
:href="taskLink(t)"
:task="t"
:itemType="itemType"
:key="t._id"
:title="taskTitle(t)"
@click.prevent="onTaskClicked(t)"
@item-clicked="$emit('item-clicked', ...arguments)"
/>
</div>
<button class="add-task-link"
v-if="canAddTask"
@click.prevent="onAddTask"
@click.prevent.stop="onAddTask"
>
<i class="pi-plus">Task</i>
<i class="pi-plus">Task</i>
</button>
</div>
`;
@@ -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;
}
},
});

View File

@@ -0,0 +1,33 @@
const TEMPLATE =`
<a class="task"
:class="taskClass"
:href="taskLink"
:title="taskTitle"
@click.prevent.stop="$emit('item-clicked', arguments[0], task.getId())"
/>
`;
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 }

View File

@@ -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) || '';
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
});
}
}

View File

@@ -0,0 +1,9 @@
const TEMPLATE =`
<div class="attract-box item-details-empty">Select Something</div>
`;
let Empty = Vue.component('attract-editor-empty', {
template: TEMPLATE,
});
export {Empty}

View File

@@ -0,0 +1,11 @@
const TEMPLATE =`
<div class="attract-box multiple-types">
Objects of different types selected
</div>
`;
let MultipleTypes = Vue.component('attract-editor-multiple-types', {
template: TEMPLATE,
});
export {MultipleTypes}

View File

@@ -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 =`
<div class="attract-detailed-view">
<div class="col_header">
<span class="header_text">
{{ headerText }}
<i
v-if="isMultiItemsView"
class="pi-link"
title="Multiple items selected"
/>
</span>
</div>
<component
:is="editorType"
:items="items"
:project="project"
:contextType="contextType"
@objects-are-edited="$emit('objects-are-edited', ...arguments)"
@saved-items="activitiesIsOutdated"
/>
<attract-activities
v-if="isSingleItemView"
:objectId="singleObjectId"
:outdated="isActivitiesOutdated"
@activities-updated="activitiesIsUpToDate"
/>
<comments-tree
v-if="isSingleItemView"
:parentId="singleObjectId"
@new-comment="activitiesIsOutdated"
/>
</div>
`;
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
}
},
});

View File

@@ -0,0 +1,113 @@
import './base/TextArea'
import './base/ConclusiveMark'
import {EditorBase, BaseProps} from './base/EditorBase'
const TEMPLATE =`
<div class="attract-box asset with-status"
:class="editorClasses"
>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="nameProp"
/>
<input class="item-name" name="name" type="text" placeholder="Asset Name"
:disabled="!canEdit"
:class="classesForProperty(nameProp)"
v-model="nameProp.value"/>
<button class="copy-to-clipboard btn item-id" name="Copy to Clipboard" type="button" title="Copy ID to clipboard"
v-if="!isMultpleItems"
:data-clipboard-text="items[0]._id"
>
ID
</button>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="descriptionProp"
/>
<attract-editor-text-area
placeholder="Description"
:disabled="!canEdit"
:class="classesForProperty(descriptionProp)"
v-model="descriptionProp.value"
/>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="statusProp"
/>
<label for="item-status">Status:</label>
<select class="input-transparent" id="item-status" name="status"
:class="classesForProperty(statusProp)"
:disabled="!canEdit"
v-model="statusProp.value"
>
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
<option v-for="it in allowedStatusesPair"
:key="it.id"
:value="it.id"
>{{it.text}}</option>
</select>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="notesProp"
/>
<attract-editor-text-area
placeholder="Notes"
:class="classesForProperty(notesProp)"
:disabled="!canEdit"
v-model="notesProp.value"
/>
</div>
<div class="input-group-separator"/>
<div class="input-group"
v-if="canEdit"
>
<button class="btn btn-outline-success btn-block" id="item-save" type="submit"
@click="save"
>
<i class="pi-check"/>Save Asset
</button>
</div>
</div>
`;
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}

View File

@@ -0,0 +1,246 @@
import './base/TextArea'
import './base/ConclusiveMark'
import {EditorBase, BaseProps} from './base/EditorBase'
const TEMPLATE =`
<div>
<div class="attract-box shot with-status"
:class="editorClasses"
>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="nameProp"
/>
<span title="Shot names can only be updated from Blender." class="item-name">
{{ valueOrNA(nameProp.value) }}
</span>
<button class="copy-to-clipboard btn item-id" name="Copy to Clipboard" type="button" title="Copy ID to clipboard"
v-if="!isMultpleItems"
:data-clipboard-text="items[0]._id"
>
ID
</button>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="descriptionProp"
/>
<attract-editor-text-area
placeholder="Description"
:disabled="!canEdit"
:class="classesForProperty(descriptionProp)"
v-model="descriptionProp.value"
/>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="statusProp"
/>
<label for="item-status">
Status:
</label>
<select id="item-status" name="status" class="input-transparent"
:class="classesForProperty(statusProp)"
:disabled="!canEdit"
v-model="statusProp.value"
>
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
<option v-for="it in allowedStatusesPair"
:key="it.id"
:value="it.id"
>{{it.text}}</option>
</select>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="notesProp"
/>
<attract-editor-text-area
placeholder="Notes"
:class="classesForProperty(notesProp)"
:disabled="!canEdit"
v-model="notesProp.value"
/>
</div>
<div class="input-group-separator"/>
<div class="input-group"
v-if="canEdit"
>
<button class="btn btn-outline-success btn-block" id="item-save" type="submit"
@click="save"
>
<i class="pi-check"/>Save Shot
</button>
</div>
</div>
<div class="attract-box">
<div class="table item-properties">
<div class="table-body">
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="updatedProp"
/>
Last Update
</div>
<div :title="updatedProp.value" class="table-cell">
<span role="button" data-toggle="collapse" data-target="#task-time-creation" aria-expanded="false" aria-controls="#task-time-creation">
{{ prettyDate(updatedProp.value) }}
</span>
<div id="task-time-creation" class="collapse">
{{ prettyDate(createdProp.value) }}
</div>
</div>
</div>
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="usedInEditProp"
/>
Used in Edit
</div>
<div title="Whether this shot is used in the edit." class="table-cell text-capitalize">
{{ formatBool(usedInEditProp.value) }}
</div>
</div>
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="cutInTimelineProp"
/>
Cut-in
</div>
<div title="Frame number of the first visible frame of this shot." class="table-cell">
at frame {{ valueOrNA(cutInTimelineProp.value) }}
</div>
</div>
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="trimStartProp"
/>
Trim Start
</div>
<div title="How many frames were trimmed off the start of the shot in the edit." class="table-cell">
{{ valueOrNA(trimStartProp.value) }} frames
</div>
</div>
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="trimEndProp"
/>
Trim End
</div>
<div title="How many frames were trimmed off the end of the shot in the edit." class="table-cell">
{{ valueOrNA(trimEndProp.value) }} frames
</div>
</div>
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="durationInEditProp"
/>
Duration in Edit
</div>
<div title="Duration of the visible part of this shot." class="table-cell">
{{ valueOrNA(durationInEditProp.value) }} frames
</div>
</div>
</div>
</div>
</div>
</div>
`;
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}

View File

@@ -0,0 +1,244 @@
import './base/TextArea'
import './base/ConclusiveMark'
import './base/Select2'
import './base/DatePicker'
import {EditorBase, BaseProps} from './base/EditorBase'
const TEMPLATE =`
<div class="attract-box task with-status"
:class="editorClasses"
>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="nameProp"
/>
<input class="item-name" name="name" type="text" placeholder="Task Title"
:disabled="!canEdit"
:class="classesForProperty(nameProp)"
v-model="nameProp.value"/>
<div class="dropdown" style="margin-left: auto"
v-if="canEdit"
>
<button class="btn btn-outline-success dropdown-toggle" id="item-dropdown" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
<i class="pi-more-vertical"/>
</button>
<ul class="dropdown-menu" aria-labelledby="item-dropdown">
<li class="copy-to-clipboard"
v-if="!isMultpleItems"
:data-clipboard-text="items[0]._id">
<a href="javascript:void(0)">
<i class="pi-clipboard-copy"/>
Copy ID to Clipboard
</a>
</li>
<li class="copy-to-clipboard"
v-if="!isMultpleItems"
:data-clipboard-text="'[' + items[0].properties.shortcode + ']'">
<a href="javascript:void(0)">
<i class="pi-clipboard-copy"/>
Copy Shortcode for SVN Commits to Clipboard
</a>
</li>
<li class="divider" role="separator"/>
<li class="item-delete">
<a href="javascript:void(0)"
@click="deleteTasks"
>
<i class="pi-trash"/>
{{ isMultpleItems ? "Delete Tasks" : "Delete Task" }}
</a>
</li>
</ul>
</div>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="descriptionProp"
/>
<attract-editor-text-area
placeholder="Description"
:disabled="!canEdit"
:class="classesForProperty(descriptionProp)"
v-model="descriptionProp.value"
/>
</div>
<div class="input-group-flex">
<div class="input-group field-type"
v-if="(canChangeTaskType)"
>
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="taskTypeProp"
/>
<label id="task-task_type">
Type:
</label>
<select name="task_type" aria-describedby="task-task_type"
:class="classesForProperty(taskTypeProp)"
:disabled="!canEdit"
v-model="taskTypeProp.value"
>
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
<option v-for="it in allowedTaskTypesPair"
:key="it.id"
:value="it.id"
>{{it.text}}</option>
</select></div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="statusProp"
/>
<label for="item-status">Status:</label>
<select class="input-transparent" id="item-status" name="status"
:class="classesForProperty(statusProp)"
:disabled="!canEdit"
v-model="statusProp.value"
>
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
<option v-for="it in allowedStatusesPair"
:key="it.id"
:value="it.id"
>{{it.text}}</option>
</select>
</div>
</div>
<div class="input-group-separator"/>
<div class="input-group select_multiple">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="assignedToProp"
/>
<label>
Assignees:
</label>
<attract-select2
:class="classesForProperty(assignedToProp)"
:options="users"
:disabled="!canEdit"
v-model="assignedToProp.value">
<option value=undefined disabled="true" v-if="!assignedToProp.isConclusive()"> *** </option>
</attract-select2>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="dueDateProp"
/>
<label>
Due Date:
</label>
<attract-date-picker id="item-due_date" name="due_date" placeholder="Deadline for Task"
:class="classesForProperty(dueDateProp)"
:disabled="!canEdit"
v-model="dueDateProp.value"
/>
</div>
<div class="input-group-separator"/>
<div class="input-group"
v-if="canEdit"
>
<button class="btn btn-outline-success btn-block" id="item-save" type="submit"
@click="save"
>
<i class="pi-check"/>
Save Task
</button>
</div>
</div>
`;
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}

View File

@@ -0,0 +1,42 @@
const TEMPLATE =`
<i
:class="classes"
:title="toolTip"
/>
`;
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)}...`
}
},
});

View File

@@ -0,0 +1,50 @@
/**
* Wrapper around Pikaday
*/
let TEMPLATE = `
<input ref="datepicker" type="text">
`;
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);
}
},
})

View File

@@ -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}

View File

@@ -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<Object>} 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<Object>}
*/
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 }

View File

@@ -0,0 +1,56 @@
/**
* Wrapper around jquery select2. Heavily inspired by: https://vuejs.org/v2/examples/select2.html
*/
let TEMPLATE = `
<div class="input-group attract-select2">
<select multiple="" ref="select2" style="display: none;" id="apa"
:disabled="disabled"
>
<slot/>
</select>
</div>
`;
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());
});
}
},
})

View File

@@ -0,0 +1,31 @@
const TEMPLATE = `
<textarea ref="inputField"
v-bind:value="value"
v-on:input="$emit('input', $event.target.value)"
class="input-transparent"
type="text"
rows="2"/>
`;
Vue.component('attract-editor-text-area', {
template: TEMPLATE,
props: {
value: String,
},
watch:{
value() {
this.$nextTick(this.autoSizeInputField);
}
},
mounted() {
this.$nextTick(this.autoSizeInputField);
},
methods: {
autoSizeInputField() {
let elInputField = this.$refs.inputField;
elInputField.style.cssText = 'height:auto; padding:0';
let newInputHeight = elInputField.scrollHeight + 20;
elInputField.style.cssText = `height:${ newInputHeight }px`;
}
},
});

View File

@@ -1,3 +1,6 @@
import './assetstable/Table'
import './taskstable/Table'
import './shotstable/Table'
import './App'
import './activities/Activities'
import './detailedview/Viewer'

View File

@@ -3,7 +3,7 @@ import {ShotsColumnFactory} from './columns/ShotsColumnFactory'
import {ShotRowsSource} from './rows/ShotRowsSource'
import {RowFilter} from '../attracttable/filter/RowFilter'
Vue.component('attract-shots-table', {
let ShotsTable = Vue.component('attract-shots-table', {
extends: PillarTable,
columnFactory: ShotsColumnFactory,
rowsSource: ShotRowsSource,
@@ -11,3 +11,5 @@ Vue.component('attract-shots-table', {
'pillar-table-row-filter': RowFilter,
},
});
export { ShotsTable }

View File

@@ -1,5 +1,6 @@
import {AttractRowBase} from '../../attracttable/rows/AttractRowBase'
import { TaskEventListener } from '../../attracttable/rows/TaskEventListener';
import { TaskRow } from '../../taskstable/rows/TaskRow';
class ShotRow extends AttractRowBase {
constructor(shot) {
@@ -7,12 +8,15 @@ class ShotRow extends AttractRowBase {
this.tasks = [];
}
thenInit() {
_thenInitImpl() {
return attract.api.thenGetTasks(this.getId())
.then((response) => {
this.tasks = response._items;
this.tasks = response._items.map(t => new TaskRow(t));
this.registerTaskEventListeners();
this.isInitialized = true;
return Promise.all(
this.tasks.map(t => t.thenInit())
);
})
}
@@ -22,7 +26,7 @@ class ShotRow extends AttractRowBase {
getTasksOfType(taskType) {
return this.tasks.filter((t) => {
return t.properties.task_type === taskType;
return t.getProperties().task_type === taskType;
})
}
@@ -33,6 +37,10 @@ class ShotRow extends AttractRowBase {
}
return classes;
}
getChildObjects() {
return this.tasks;
}
}
export { ShotRow }

View File

@@ -6,7 +6,7 @@ class ShotRowsSource extends AttractRowsSourceBase {
super(projectId, 'attract_asset', ShotRow);
}
thenInit() {
thenFetchObjects() {
return attract.api.thenGetProjectShots(this.projectId)
.then((result) => {
let shots = result._items;

View File

@@ -23,13 +23,16 @@ let TableActions = {
}
},
methods: {
createNewTask() {
task_create(undefined, 'generic');
createNewTask(event) {
thenCreateTask(undefined, 'generic')
.then((task) => {
this.$emit('item-clicked', event, task._id);
});
}
},
}
Vue.component('attract-tasks-table', {
let TasksTable = Vue.component('attract-tasks-table', {
extends: PillarTable,
columnFactory: TasksColumnFactory,
rowsSource: TaskRowsSource,
@@ -38,3 +41,5 @@ Vue.component('attract-tasks-table', {
'pillar-table-row-filter': RowFilter,
}
});
export {TasksTable}

View File

@@ -4,7 +4,7 @@ const TEMPLATE =`
<div>
<a
v-if="rawCellValue"
@click.prevent="onClick()"
@click="onClick"
:href="cellLink"
>
{{ cellValue }}
@@ -29,12 +29,12 @@ let ParentNameCell = Vue.component('pillar-cell-parent-name', {
}
},
methods: {
onClick() {
item_open(this.rowObject.getParent()._id, this.itemType(), false, ProjectUtils.projectUrl());
onClick(event) {
event.preventDefault(); // Don't follow link, but let event bubble and the row will handle it
},
itemType() {
let node_type = this.rowObject.getParent().node_type;
return node_type.replace('attract_', ''); // eg. attract_task to tasks
return node_type.replace('attract_', ''); // eg. attract_task to task
}
},
});

View File

@@ -4,7 +4,7 @@ class TaskRow extends AttractRowBase {
constructor(task) {
super(task);
this.parent = undefined;
if (task.parent) {
if (typeof task.parent === 'object') {
// Deattach parent from task to avoid parent to be overwritten when task is updated
let parentId = task.parent._id;
this.parent = task.parent;
@@ -21,8 +21,8 @@ class TaskRow extends AttractRowBase {
return this.parent;
}
onParentUpdated(event, updatedObj) {
this.parent = updatedObj;
onParentUpdated(event) {
this.parent = event.detail;
}
}

View File

@@ -6,7 +6,7 @@ class TaskRowsSource extends AttractRowsSourceBase {
super(projectId, 'attract_task', TaskRow);
}
thenInit() {
thenFetchObjects() {
return attract.api.thenGetProjectTasks(this.projectId)
.then((result) => {
let tasks = result._items;