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:
@@ -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)
|
||||
|
||||
|
||||
|
@@ -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'])
|
||||
|
@@ -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)
|
||||
|
||||
|
||||
|
@@ -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 }
|
||||
|
@@ -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;
|
||||
|
161
src/scripts/js/es6/common/vuecomponents/App.js
Normal file
161
src/scripts/js/es6/common/vuecomponents/App.js
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
@@ -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();
|
@@ -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');
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
@@ -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 };
|
||||
|
@@ -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 }
|
||||
|
@@ -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;
|
||||
|
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -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 }
|
@@ -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) || '';
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -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}
|
@@ -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}
|
114
src/scripts/js/es6/common/vuecomponents/detailedview/Viewer.js
Normal file
114
src/scripts/js/es6/common/vuecomponents/detailedview/Viewer.js
Normal 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
|
||||
}
|
||||
},
|
||||
});
|
||||
|
113
src/scripts/js/es6/common/vuecomponents/editor/AssetEditor.js
Normal file
113
src/scripts/js/es6/common/vuecomponents/editor/AssetEditor.js
Normal 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}
|
246
src/scripts/js/es6/common/vuecomponents/editor/ShotEditor.js
Normal file
246
src/scripts/js/es6/common/vuecomponents/editor/ShotEditor.js
Normal 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}
|
244
src/scripts/js/es6/common/vuecomponents/editor/TaskEditor.js
Normal file
244
src/scripts/js/es6/common/vuecomponents/editor/TaskEditor.js
Normal 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}
|
@@ -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)}...`
|
||||
}
|
||||
},
|
||||
});
|
@@ -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);
|
||||
}
|
||||
},
|
||||
})
|
@@ -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}
|
||||
|
@@ -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 }
|
@@ -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());
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
@@ -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`;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@@ -1,3 +1,6 @@
|
||||
import './assetstable/Table'
|
||||
import './taskstable/Table'
|
||||
import './shotstable/Table'
|
||||
import './App'
|
||||
import './activities/Activities'
|
||||
import './detailedview/Viewer'
|
||||
|
@@ -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 }
|
||||
|
@@ -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 }
|
||||
|
@@ -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;
|
||||
|
@@ -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}
|
||||
|
@@ -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
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -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())) {
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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 %}
|
||||
|
@@ -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) }}");
|
||||
|
||||
|
@@ -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 %}
|
||||
|
@@ -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 %}
|
||||
|
@@ -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 %}
|
||||
|
@@ -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')}}")
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user