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

@@ -24,16 +24,17 @@ log = logging.getLogger(__name__)
@perproject_blueprint.route('/with-task/<task_id>', 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)

View File

@@ -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('/<shot_id>', methods=['POST'])

View File

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

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;

View File

@@ -1,92 +1,7 @@
/**
* Open an item such as tasks/shots in the #item-details div
*/
function item_open(item_id, item_type, pushState, project_url)
{
if (item_id === undefined || item_type === undefined) {
throw new ReferenceError("item_open(" + item_id + ", " + item_type + ") called.");
}
if (typeof project_url === 'undefined') {
project_url = ProjectUtils.projectUrl();
if (typeof project_url === 'undefined') {
throw new ReferenceError("ProjectUtils.projectUrl() undefined");
}
}
// Special case to highlight the shot row when opening task in shot or asset context
var pu_ctx = ProjectUtils.context();
var pc_ctx_shot_asset = (pu_ctx == 'shot' || pu_ctx == 'asset');
var item_url = '/attract/' + project_url + '/' + item_type + 's/' + item_id;
var push_url = item_url;
if (pc_ctx_shot_asset && item_type == 'task'){
push_url = '/attract/' + project_url + '/' + pu_ctx + 's/with-task/' + item_id;
}
item_url += '?context=' + pu_ctx;
$.get(item_url, function(item_data) {
$('#item-details').html(item_data);
$('#col_right .col_header span.header_text').text(item_type + ' details');
}).fail(function(xhr) {
if (console) {
console.log('Error fetching task', item_id, 'from', item_url);
console.log('XHR:', xhr);
}
toastr.error('Failed to open ' + item_type);
if (xhr.status) {
$('#item-details').html(xhr.responseText);
} else {
$('#item-details').html('<p class="text-danger">Opening ' + item_type + ' failed. There possibly was ' +
'an error connecting to the server. Please check your network connection and ' +
'try again.</p>');
}
});
// Determine whether we should push the new state or not.
pushState = (typeof pushState !== 'undefined') ? pushState : true;
if (!pushState) return;
// Push the correct URL onto the history.
var push_state = {itemId: item_id, itemType: item_type};
window.history.pushState(
push_state,
item_type + ': ' + item_id,
push_url
);
}
// Fine if project_url is undefined, but that requires ProjectUtils.projectUrl().
function task_open(task_id, project_url)
{
item_open(task_id, 'task', true, project_url);
}
function shot_open(shot_id)
{
item_open(shot_id, 'shot');
}
function asset_open(asset_id)
{
item_open(asset_id, 'asset');
}
window.onpopstate = function(event)
{
var state = event.state;
if(!state) return;
item_open(state.itemId, state.itemType, false);
}
/**
* Create a asset and show it in the #item-details div.
*/
function asset_create(project_url)
function thenCreateAsset(project_url)
{
if (project_url === undefined) {
throw new ReferenceError("asset_create(" + project_url+ ") called.");
@@ -97,10 +12,9 @@ function asset_create(project_url)
project_url: project_url
};
$.post(url, data, function(asset_data) {
/* window.location.href = asset_data.asset_id; */
return $.post(url, data, function(asset_data) {
pillar.events.Nodes.triggerCreated(asset_data);
asset_open(asset_data._id);
return asset_data;
})
.fail(function(xhr) {
if (console) {
@@ -118,7 +32,7 @@ function asset_create(project_url)
* 'shot_id' may be undefined, in which case the task will not
* be attached to a shot.
*/
function task_create(shot_id, task_type)
function thenCreateTask(shot_id, task_type)
{
if (task_type === undefined) {
throw new ReferenceError("task_create(" + shot_id + ", " + task_type + ") called.");
@@ -133,10 +47,10 @@ function task_create(shot_id, task_type)
};
if (has_shot_id) data.parent = shot_id;
$.post(url, data, function(task_data) {
return $.post(url, data, function(task_data) {
if (console) console.log('Task created:', task_data);
pillar.events.Nodes.triggerCreated(task_data);
task_open(task_data._id);
return task_data;
})
.fail(function(xhr) {
if (console) {
@@ -144,167 +58,9 @@ function task_create(shot_id, task_type)
console.log('XHR:', xhr);
}
$('#item-details').html(xhr.responseText);
})
.done(function(){
$('#item-details input[name="name"]').focus();
});
}
function attract_form_save(form_id, item_id, item_save_url, options)
{
// Mandatory option.
if (typeof options === 'undefined' || typeof options.type === 'undefined') {
throw new ReferenceError('attract_form_save(): options.type is mandatory.');
}
var $form = $('#' + form_id);
var $button = $form.find("button[type='submit']");
var payload = $form.serialize();
var $item = $('#' + item_id);
$button.attr('disabled', true);
if (console) console.log('Sending:', payload);
$.post(item_save_url, payload)
.done(function(saved_item) {
if (console) console.log('Done saving', saved_item);
toastr.success('Saved ' + options.type + '. ' + saved_item._updated);
pillar.events.Nodes.triggerUpdated(saved_item);
$form.find("input[name='_etag']").val(saved_item._etag);
if (options.done) options.done($item, saved_item);
})
.fail(function(xhr_or_response_data) {
// jQuery sends the response data (if JSON), or an XHR object (if not JSON).
if (console) console.log('Failed saving', options.type, xhr_or_response_data);
$button.removeClass('btn-outline-success').addClass('btn-danger');
toastr.error('Failed saving. ' + xhr_or_response_data.status);
if (options.fail) options.fail($item, xhr_or_response_data);
})
.always(function() {
$button.attr('disabled', false);
if (options.always) options.always($item);
})
;
return false; // prevent synchronous POST to current page.
}
function task_save(task_id, task_url) {
return attract_form_save('item_form', 'task-' + task_id, task_url, {
done: function($task, saved_task) {
task_open(task_id);
},
fail: function($item, xhr_or_response_data) {
if (xhr_or_response_data.status == 412) {
// TODO: implement something nice here. Just make sure we don't throw
// away the user's edits. It's up to the user to handle this.
} else {
$('#item-details').html(xhr_or_response_data.responseText);
}
},
type: 'task'
});
}
function shot_save(shot_id, shot_url) {
return attract_form_save('item_form', 'shot-' + shot_id, shot_url, {
done: function($shot, saved_shot) {
shot_open(shot_id);
},
fail: function($item, xhr_or_response_data) {
if (xhr_or_response_data.status == 412) {
// TODO: implement something nice here. Just make sure we don't throw
// away the user's edits. It's up to the user to handle this.
} else {
$('#item-details').html(xhr_or_response_data.responseText);
}
},
type: 'shot'
});
}
function asset_save(asset_id, asset_url) {
return attract_form_save('item_form', 'asset-' + asset_id, asset_url, {
done: function($asset, saved_asset) {
asset_open(asset_id);
},
fail: function($item, xhr_or_response_data) {
if (xhr_or_response_data.status == 412) {
// TODO: implement something nice here. Just make sure we don't throw
// away the user's edits. It's up to the user to handle this.
} else {
$('#item-details').html(xhr_or_response_data.responseText);
}
},
type: 'asset'
});
}
function task_delete(task_id, task_etag, task_delete_url) {
if (task_id === undefined || task_etag === undefined || task_delete_url === undefined) {
throw new ReferenceError("task_delete(" + task_id + ", " + task_etag + ", " + task_delete_url + ") called.");
}
$.ajax({
type: 'DELETE',
url: task_delete_url,
data: {'etag': task_etag}
})
.done(function(e) {
if (console) console.log('Task', task_id, 'was deleted.');
$('#item-details').fadeOutAndClear();
pillar.events.Nodes.triggerDeleted(task_id);
toastr.success('Task deleted');
})
.fail(function(xhr) {
toastr.error('Unable to delete task, code ' + xhr.status);
if (xhr.status == 412) {
alert('Someone else edited this task before you deleted it; refresh to try again.');
// TODO: implement something nice here. Just make sure we don't throw
// away the user's edits. It's up to the user to handle this.
// TODO: refresh activity feed and point user to it.
} else {
// TODO: find a better place to put this error message, without overwriting the
// task the user is looking at in-place.
$('#task-view-feed').html(xhr.responseText);
}
});
}
function loadActivities(url)
{
return $.get(url)
.done(function(data) {
if(console) console.log('Activities loaded OK');
$('#activities').html(data);
})
.fail(function(xhr) {
if (console) {
console.log('Error fetching activities');
console.log('XHR:', xhr);
}
toastr.error('Opening activity log failed.');
if (xhr.status) {
$('#activities').html(xhr.responseText);
} else {
$('#activities').html('<p class="text-danger">Opening activity log failed. There possibly was ' +
'an error connecting to the server. Please check your network connection and ' +
'try again.</p>');
}
});
}
var save_on_ctrl_enter = ['shot', 'asset', 'task'];
$(document).on('keyup', function(e){
if ($.inArray(save_on_ctrl_enter, ProjectUtils.context())) {

View File

@@ -301,6 +301,13 @@ nav.sidebar
&:hover
color: $color-text-dark-primary
border-color: $color-text-dark-primary
textarea
overflow-y: hidden // there is js in place to make them grow as needed instead
resize: none
.edited
background-color: $color-status-updated
#item-details
@@ -327,3 +334,15 @@ nav.sidebar
#comments-container
margin-top: 0
.attract-app
display: flex
width: 100%
.attract-detailed-view
overflow: scroll
.col_header
position: sticky
top: 0px
z-index: $zindex-sticky

View File

@@ -57,6 +57,9 @@ $thumbnail-max-height: calc(110px * (9/16))
+stripes-animate
+stripes(transparent, rgba($color-background-active, .6), -45deg, 4em)
animation-duration: 4s
&.active
border-left: 0.5em solid $color-background-active
.pillar-table-container.attract-tasks-table
.pillar-table-row

View File

@@ -2,19 +2,8 @@
| {% block bodyattrs %}{{ super() }} data-context='asset'{% endblock %}
| {% block page_title %}Assets - {{ project.name }}{% endblock %}
| {% block attractbody %}
#col_main
attract-assets-table#table(
project-id="{{ project._id}}"
)
| <attract-app project-id="{{ project._id }}" {%if selected_id %}:selected-ids=["{{ selected_id }}"] {% endif %} id="col_main2" context-type="assets"/>
.col-splitter
#col_right
.col_header
span.header_text
#item-details.col-scrollable
.item-details-empty
| Select an Asset or Task
| {% endblock %}
| {% block footer_scripts %}
@@ -22,23 +11,25 @@ script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min
script(src="{{ url_for('static_attract', filename='assets/js/vendor/jquery-resizable-0.20.min.js')}}")
script.
{% if can_use_attract %}
attract.auth.AttractAuth.setUserCanUseAttract('{{project._id}}', true);
{% endif %}
{% if can_create_task %}
attract.auth.AttractAuth.setUserCanCreateTask('{{project._id}}', true);
{% endif %}
{% if can_create_asset %}
attract.auth.AttractAuth.setUserCanCreateAsset('{{project._id}}', true);
{% endif %}
{% if open_task_id %}
$(function() { item_open('{{ open_task_id }}', 'task', false); });
{% endif %}
{% if open_asset_id %}
$(function() { item_open('{{ open_asset_id }}', 'asset', false); });
{% endif %}
script.
new Vue({el:'#table'})
new Vue({el:'#col_main2'})
$("#col_main").resizable({
handleSelector: ".col-splitter",
resizeHeight: false,
});
var clipboard = new Clipboard('.copy-to-clipboard');
clipboard.on('success', function(e) {
toastr.info('Copied to clipboard')
});
| {% endblock footer_scripts %}

View File

@@ -1,77 +1,7 @@
.attract-box.asset.with-status(class="status-{{ asset.properties.status }}")
form#item_form(onsubmit="return asset_save('{{asset._id}}', '{{ url_for('attract.assets.perproject.save', project_url=project['url'], asset_id=asset._id) }}')")
input(type='hidden',name='_etag',value='{{ asset._etag }}')
.input-group
| {% if can_edit %}
input.item-name(
name="name",
type="text",
placeholder='Asset Name',
value="{{ asset.name | hide_none }}")
| {% else %}
span.item-name {{ asset.name | hide_none }}
| {% endif %}
button.copy-to-clipboard.btn.item-id(
name="Copy to Clipboard",
type="button",
data-clipboard-text="{{ asset._id }}",
title="Copy ID to clipboard")
| ID
| {% if can_edit %}
.input-group
textarea#item-description.input-transparent(
name="description",
type="text",
rows=1,
placeholder='Description') {{ asset.description | hide_none }}
.input-group
label(for="item-status") Status:
select#item-status.input-transparent(
name="status")
| {% for status in asset_node_type.dyn_schema.status.allowed %}
| <option value="{{ status }}" {% if status == asset.properties.status %}selected{% endif %}>{{ status | undertitle }}</option>
| {% endfor %}
.input-group
textarea#item-notes.input-transparent(
name="notes",
type="text",
rows=1,
placeholder='Notes') {{ asset.properties.notes | hide_none }}
.input-group-separator
.input-group
button#item-save.btn.btn-outline-success.btn-block(type='submit')
i.pi-check
| Save Asset
| {% else %}
//- NOTE: read-only versions of the fields above.
| {% if asset.description %}
p.item-description {{ asset.description | hide_none }}
| {% endif %}
.table.item-properties
.table-body
.table-row.properties-status.js-help(
data-url="{{ url_for('attract.help', project_url=project.url) }}")
.table-cell Status
.table-cell(class="status-{{ asset.properties.status }}")
| {{ asset.properties.status | undertitle }}
| {% if asset.properties.notes %}
.table-row
.table-cell Notes
.table-cell
| {{ asset.properties.notes | hide_none }}
| {% endif %}
| {% endif %}
attract-asset-editor#editor
#item-view-feed
#activities
attract-activities#activities2(object-id="{{ asset['_id'] }}")
| <comments-tree id='comments-embed' parent-id="{{ asset['_id'] }}" {%if not can_edit %}read-only{% endif %}/>
| {% if config.DEBUG %}
@@ -95,16 +25,9 @@ script.
toastr.info('Copied asset ID to clipboard')
});
var activities_url = "{{ url_for('.activities', project_url=project.url, asset_id=asset['_id']) }}";
loadActivities(activities_url); // from 10_tasks.js
new Vue({el:'#editor'});
new Vue({el:'#comments-embed'});
$('body').on('pillar:comment-posted', function(e, comment_node_id) {
loadActivities(activities_url)
.done(function() {
$('#' + comment_node_id).scrollHere();
});
});
new Vue({el:'#activities2'});
$('.js-help').openModalUrl('Help', "{{ url_for('attract.help', project_url=project.url) }}");

View File

@@ -57,7 +57,7 @@ script(src="{{ url_for('static_attract', filename='assets/js/generated/tutti.min
| {% endblock body %}
| {% block footer_scripts_pre %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.select2.min.js') }}", async=true)
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.select2.min.js') }}")
script(src="{{ url_for('static_attract', filename='assets/js/vendor/moment-2.15.2.min.js') }}")
script(src="{{ url_for('static_attract', filename='assets/js/vendor/pikaday.js') }}")
| {% if project %}

View File

@@ -2,19 +2,8 @@
| {% block bodyattrs %}{{ super() }} data-context='shot'{% endblock %}
| {% block page_title %}Shots - {{ project.name }}{% endblock %}
| {% block attractbody %}
#col_main
attract-shots-table#table(
project-id="{{ project._id}}"
)
| <attract-app project-id="{{ project._id }}" {%if selected_id %}:selected-ids=["{{ selected_id }}"] {% endif %} id="col_main2" context-type="shots"/>
.col-splitter
#col_right
.col_header
span.header_text
#item-details.col-scrollable
.item-details-empty
| Select a Shot or Task
| {% endblock %}
| {% block footer_scripts %}
@@ -22,21 +11,25 @@ script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min
script(src="{{ url_for('static_attract', filename='assets/js/vendor/jquery-resizable-0.20.min.js')}}")
script.
{% if can_use_attract %}
attract.auth.AttractAuth.setUserCanUseAttract('{{project._id}}', true);
{% endif %}
{% if can_create_task %}
attract.auth.AttractAuth.setUserCanCreateTask('{{project._id}}', true);
{% endif %}
// Open task or shot on load
{% if open_task_id %}
$(function() { item_open('{{ open_task_id }}', 'task', false); });
{% endif %}
{% if open_shot_id %}
$(function() { item_open('{{ open_shot_id }}', 'shot', false); });
{% if can_create_asset %}
attract.auth.AttractAuth.setUserCanCreateAsset('{{project._id}}', true);
{% endif %}
script.
new Vue({el:'#table'})
new Vue({el:'#col_main2'})
$("#col_main").resizable({
handleSelector: ".col-splitter",
resizeHeight: false,
});
var clipboard = new Clipboard('.copy-to-clipboard');
clipboard.on('success', function(e) {
toastr.info('Copied to clipboard')
});
| {% endblock footer_scripts %}

View File

@@ -1,39 +1,35 @@
| {% extends 'attract/layout.html' %}
| {% block bodyattrs %}{{ super() }} data-context='task'{% endblock %}
| {% block page_title %}Tasks - {{ project.name }} {% endblock %}
| {% block page_title %}Tasks - {{ project.name }}{% endblock %}
| {% block attractbody %}
| <attract-app project-id="{{ project._id }}" {%if selected_id %}:selected-ids=["{{ selected_id }}"] {% endif %} id="col_main2" context-type="tasks"/>
#col_main
attract-tasks-table#table(
project-id="{{ project._id}}"
)
.col-splitter
#col_right
.col_header
span.header_text
#item-details.col-scrollable
.item-details-empty
| Select a Task
| {% endblock %}
| {% block footer_scripts %}
script.
{% if open_task_id %}
$(function() { item_open('{{ open_task_id }}', 'task', false); });
{% endif %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}")
script(src="{{ url_for('static_attract', filename='assets/js/vendor/jquery-resizable-0.20.min.js')}}")
script.
{% if can_use_attract %}
attract.auth.AttractAuth.setUserCanUseAttract('{{project._id}}', true);
{% endif %}
{% if can_create_task %}
attract.auth.AttractAuth.setUserCanCreateTask('{{project._id}}', true);
{% endif %}
new Vue({el:'#table'})
{% if can_create_asset %}
attract.auth.AttractAuth.setUserCanCreateAsset('{{project._id}}', true);
{% endif %}
new Vue({el:'#col_main2'})
$("#col_main").resizable({
handleSelector: ".col-splitter",
resizeHeight: false,
});
var clipboard = new Clipboard('.copy-to-clipboard');
clipboard.on('success', function(e) {
toastr.info('Copied to clipboard')
});
| {% endblock footer_scripts %}

View File

@@ -16,10 +16,6 @@
| {% endblock %}
| {% block footer_scripts %}
script.
{% if open_task_id %}
$(function() { task_open('{{ open_task_id }}'); });
{% endif %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}")
script(src="{{ url_for('static_attract', filename='assets/js/vendor/jquery-resizable-0.20.min.js')}}")

View File

@@ -5,6 +5,7 @@
.item-list.col-list
| {% for task in tasks if tasks %}
//- NOTE: this is tightly linked to the JS in tasks.js, function task_add()
| {% if task._parent_info and task._parent_info.node_type == 'attract_shot'%}
a.col-list-item.task-list-item(
class="status-{{ task.properties.status }} task-link",
title="In project '{{ task._project_info.name }}'",
@@ -15,6 +16,29 @@
| {% endif %}
span.name {{ task.name }}
span.due_date {{ task.properties.due_date | pretty_date | hide_none }}
| {% elif task._parent_info and task._parent_info.node_type == 'attract_asset'%}
a.col-list-item.task-list-item(
class="status-{{ task.properties.status }} task-link",
title="In project '{{ task._project_info.name }}'",
href="{{ url_for('attract.assets.perproject.with_task', project_url=task._project_info.url, task_id=task._id) }}")
span.status-indicator
| {% if include_shotname and task._parent_info %}
span.shotname {{ task._parent_info.name }}
| {% endif %}
span.name {{ task.name }}
span.due_date {{ task.properties.due_date | pretty_date | hide_none }}
| {% else %}
a.col-list-item.task-list-item(
class="status-{{ task.properties.status }} task-link",
title="In project '{{ task._project_info.name }}'",
href="{{ url_for('attract.tasks.perproject.view_task', project_url=task._project_info.url, task_id=task._id) }}")
span.status-indicator
| {% if include_shotname and task._parent_info %}
span.shotname {{ task._parent_info.name }}
| {% endif %}
span.name {{ task.name }}
span.due_date {{ task.properties.due_date | pretty_date | hide_none }}
| {% endif %}
| {% else %}
.col-list-item.empty
span.no-tasks