Tobias Johansson 379f743864 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.
2019-03-13 13:53:40 +01:00

332 lines
11 KiB
JavaScript

import '../utils/MarkdownPreview'
import './AttachmentEditor'
import './UploadProgress'
import { thenCreateComment, thenUpdateComment } from '../../api/comments'
import { thenUploadFile } from '../../api/files'
import { Droptarget } from '../mixins/Droptarget'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { EventBus, Events } from './EventBus'
const MAX_ATTACHMENTS = 5;
const TEMPLATE =`
<div class="comment-reply-form"
:class="dropTargetClasses"
>
<div class="attachments">
<comment-attachment-editor
v-for="a in attachments"
@delete="attachmentDelete"
@insert="insertAttachment"
@rename="attachmentRename"
@validation="attachmentValidation"
@unit-of-work="childUnitOfWork"
:slug="a.slug"
:allSlugs="allSlugs"
:oid="a.oid"
:key="a.oid"
/>
<upload-progress
v-if="uploads.nbrOfActive > 0"
:label="uploadProgressLabel"
:progress="uploadProgressPercent"
/>
</div>
<div class="comment-reply-field"
:class="{filled: isMsgLongEnough}"
>
<textarea
ref="inputField"
@keyup="keyUp"
v-model="msg"
id="comment_field"
placeholder="Join the conversation...">
</textarea>
<div class="comment-reply-meta">
<button class="comment-action-submit"
:class="{disabled: !canSubmit}"
@click="submit"
type="button"
title="Post Comment (Ctrl+Enter)">
<span>
<i :class="submitButtonIcon"/>{{ submitButtonText }}
</span>
<span class="hotkey">Ctrl + Enter</span>
</button>
</div>
</div>
<markdown-preview
v-show="msg.length > 0"
:markdown="msg"
:attachments="attachmentsAsObject"
/>
</div>
`;
Vue.component('comment-editor', {
template: TEMPLATE,
mixins: [Droptarget, UnitOfWorkTracker],
props: {
user: Object,
parentId: String,
projectId: String,
comment: Object,
mode: {
type: String,
default: 'reply', // reply or update
},
},
data() {
return {
msg: this.initialMsg(),
attachments: this.initialAttachments(),
uploads: {
nbrOfActive: 0,
nbrOfTotal: 0,
total: 0,
loaded: 0
},
}
},
computed: {
submitButtonText() {
switch(this.mode) {
case 'reply': return 'Send';
case 'update': return 'Update';
default: console.error('Unknown mode: ', this.mode);
}
},
submitButtonIcon() {
if (this.isBusyWorking) {
return 'pi-spin spin';
}else{
switch(this.mode) {
case 'reply': return 'pi-paper-plane';
case 'update': return 'pi-check';
default: console.error('Unknown mode: ', this.mode);
}
}
},
attachmentsAsObject() {
let attachmentsObject = {};
for (let a of this.attachments) {
attachmentsObject[a.slug] = {oid: a.oid};
}
return attachmentsObject;
},
allSlugs() {
return this.attachments.map((a) => {
return a['slug'];
});
},
isMsgLongEnough() {
return this.msg.length >= 5;
},
isAttachmentsValid() {
for (let att of this.attachments) {
if(!att.isSlugValid) {
return false;
}
}
return true;
},
isValid() {
return this.isAttachmentsValid && this.isMsgLongEnough;
},
canSubmit() {
return this.isValid && !this.isBusyWorking;
},
uploadProgressPercent() {
if (this.uploads.nbrOfActive === 0 || this.uploads.total === 0) {
return 100;
}
return this.uploads.loaded / this.uploads.total * 100;
},
uploadProgressLabel() {
if (this.uploadProgressPercent === 100) {
return 'Processing'
}
if (this.uploads.nbrOfTotal === 1) {
return 'Uploading file';
} else {
let fileOf = this.uploads.nbrOfTotal - this.uploads.nbrOfActive + 1;
return `Uploading ${fileOf}/${this.uploads.nbrOfTotal} files`;
}
},
},
watch:{
msg(){
this.autoSizeInputField();
}
},
mounted() {
if(this.comment) {
this.$nextTick(function () {
this.autoSizeInputField();
this.$refs.inputField.focus();
})
}
},
methods: {
initialMsg() {
if (this.comment) {
if (this.mode === 'reply') {
return `***@${this.comment.user.full_name}*** `;
}
if (this.mode === 'update') {
return this.comment.msg_markdown;
}
}
return '';
},
initialAttachments() {
// Transforming the attacmentobject to an array of attachments
let attachmentsList = []
if(this.mode === 'update') {
let attachmentsObj = this.comment.properties.attachments
for (let k in attachmentsObj) {
if (attachmentsObj.hasOwnProperty(k)) {
let a = {
slug: k,
oid: attachmentsObj[k]['oid'],
isSlugValid: true
}
attachmentsList.push(a);
}
}
}
return attachmentsList;
},
submit() {
if(!this.canSubmit) return;
this.unitOfWork(
this.thenSubmit()
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to submit comment')})
)
.then(() => {
EventBus.$emit(Events.EDIT_DONE, this.comment._id);
});
},
thenSubmit() {
if (this.mode === 'reply') {
return this.thenCreateComment();
} else {
return this.thenUpdateComment();
}
},
keyUp(e) {
if ((e.keyCode == 13 || e.key === 'Enter') && e.ctrlKey) {
this.submit();
}
},
thenCreateComment() {
return thenCreateComment(this.parentId, this.msg, this.attachmentsAsObject)
.then((newComment) => {
EventBus.$emit(Events.NEW_COMMENT, newComment);
this.cleanUp();
})
},
thenUpdateComment() {
return thenUpdateComment(this.comment.parent, this.comment.id, this.msg, this.attachmentsAsObject)
.then((updatedComment) => {
EventBus.$emit(Events.UPDATED_COMMENT, updatedComment);
this.cleanUp();
})
},
canHandleDrop(event) {
let dataTransfer = event.dataTransfer;
let items = [...dataTransfer.items];
let nbrOfAttachments = items.length + this.uploads.nbrOfActive + this.attachments.length;
if(nbrOfAttachments > MAX_ATTACHMENTS) {
// Exceeds the limit
return false;
}
// Only files in drop
return [...dataTransfer.items].reduce((prev, it) => {
let isFile = it.kind === 'file' && !!it.type;
return prev && isFile;
}, !!items.length);
},
onDrop(event) {
let files = [...event.dataTransfer.files];
for (let f of files) {
this.unitOfWork(
this.thenUploadFile(f)
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'File upload failed')})
);
}
},
thenUploadFile(file){
let lastReportedTotal = 0;
let lastReportedLoaded = 0;
let progressCB = (total, loaded) => {
this.uploads.loaded += loaded - lastReportedLoaded;
this.uploads.total += total - lastReportedTotal;
lastReportedLoaded = loaded;
lastReportedTotal = total;
}
this.uploads.nbrOfActive++;
this.uploads.nbrOfTotal++;
return thenUploadFile(this.projectId || this.comment.project, file, progressCB)
.then((resp) => {
let attachment = {
slug: file.name,
oid: resp['file_id'],
isSlugValid: false
}
this.attachments.push(attachment);
this.msg += this.getAttachmentMarkdown(attachment);
})
.always(()=>{
this.uploads.nbrOfActive--;
if(this.uploads.nbrOfActive === 0) {
this.uploads.loaded = 0;
this.uploads.total = 0;
this.uploads.nbrOfTotal = 0;
}
})
},
getAttachmentMarkdown(attachment){
return `{attachment ${attachment.slug}}`;
},
insertAttachment(oid){
let attachment = this.getAttachment(oid);
this.msg += this.getAttachmentMarkdown(attachment);
},
attachmentDelete(oid) {
let attachment = this.getAttachment(oid);
let markdownToRemove = this.getAttachmentMarkdown(attachment);
this.msg = this.msg.replace(new RegExp(markdownToRemove,'g'), '');
this.attachments = this.attachments.filter((a) => {return a.oid !== oid});
},
attachmentRename(newName, oid) {
let attachment = this.getAttachment(oid);
let oldMarkdownAttachment = this.getAttachmentMarkdown(attachment);
attachment.slug = newName;
let newMarkdownAttachment = this.getAttachmentMarkdown(attachment);
this.msg = this.msg.replace(new RegExp(oldMarkdownAttachment,'g'), newMarkdownAttachment);
},
getAttachment(oid) {
for (let a of this.attachments) {
if (a.oid === oid) return a;
}
console.error('No attachment found:', oid);
},
attachmentValidation(oid, isValid) {
let attachment = this.getAttachment(oid);
attachment.isSlugValid = isValid;
},
cleanUp() {
this.msg = '';
this.attachments = [];
},
autoSizeInputField() {
let elInputField = this.$refs.inputField;
elInputField.style.cssText = 'height:auto; padding:0';
let newInputHeight = elInputField.scrollHeight + 20;
elInputField.style.cssText = `height:${ newInputHeight }px`;
}
}
});