diff --git a/CHANGELOG.md b/CHANGELOG.md index e4beef8f17..2bb830c6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Add `@apostrophecms/settings` translations. * Add the ability to have custom modals for batch operations. * Add the possibility to display utility operations inside a 3-dots menu on the page manager, the same way it is done for the docs manager. +* Move `AposDocContextMenu.vue` business logic to a mixin. * Move Pages Manager modal business logic to a mixin. Add `column.extraWidth` option (number) for `AposTreeHeader.vue` to allow control over the tree cell width. ### Changes diff --git a/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue b/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue index 97f18c7cf2..7809fa5e3a 100644 --- a/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue +++ b/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue @@ -19,449 +19,12 @@ diff --git a/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js b/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js new file mode 100644 index 0000000000..5688a9bee8 --- /dev/null +++ b/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js @@ -0,0 +1,445 @@ +import { detectDocChange } from 'Modules/@apostrophecms/schema/lib/detectChange'; +import AposPublishMixin from 'Modules/@apostrophecms/ui/mixins/AposPublishMixin'; +import AposArchiveMixin from 'Modules/@apostrophecms/ui/mixins/AposArchiveMixin'; +import AposModifiedMixin from 'Modules/@apostrophecms/ui/mixins/AposModifiedMixin'; +import checkIfConditions from 'apostrophe/lib/check-if-conditions'; + +export default { + name: 'AposDocContextMenu', + mixins: [ AposPublishMixin, AposArchiveMixin, AposModifiedMixin ], + props: { + doc: { + type: Object, + required: true + }, + // If editing in a modal, pass the current value object from the editor here + // so that the visibility of options takes unsaved changes into account + current: { + type: Object, + default() { + return null; + } + }, + // Existing published version of doc, if there is one + published: { + type: Object, + default() { + return null; + } + }, + // These props all default to true, and can be passed with a value of false to hide + // functionality even if the user can otherwise perform the action on this document. + // The component will figure out on its own if the user can perform each action or not. + showEdit: { + type: Boolean, + default() { + return true; + } + }, + showPreview: { + type: Boolean, + default() { + return true; + } + }, + showDiscardDraft: { + type: Boolean, + default() { + return true; + } + }, + showCopy: { + type: Boolean, + default() { + return true; + } + }, + showArchive: { + type: Boolean, + default() { + return true; + } + }, + showRestore: { + type: Boolean, + default() { + return true; + } + }, + // Unlike the others, this one defaults to false because it is + // generally only allowed in the Manage Submissions view. + showDismissSubmission: { + type: Boolean, + default() { + return false; + } + }, + disabled: { + type: Boolean, + default: false + } + }, + emits: [ 'menu-open', 'menu-close', 'close' ], + data() { + return { + // Updated by both the context prop and any content-changed apos events + context: this.doc, + // Custom context menu operations + customOperations: apos.modules['@apostrophecms/doc'].contextOperations + }; + }, + computed: { + menu() { + const menu = [ + // TODO + // ...(this.isModifiedFromPublished ? [ + // { + // label: 'Share Draft', + // action: 'share' + // } + // ] : []), + ...((this.showEdit && this.context._edit) ? [ + { + // When archived the edit action opens a read-only "editor" + label: this.context.archived ? 'apostrophe:view' : 'apostrophe:edit', + action: 'edit' + } + ] : []), + ...((this.showPreview && this.hasUrl) ? [ + { + label: 'apostrophe:preview', + action: 'preview' + } + ] : []), + ...((this.showDismissSubmission && this.canDismissSubmission) ? [ + { + label: 'apostrophe:dismissSubmission', + action: 'dismissSubmission' + } + ] : []), + ...(this.showCopy && this.canCopy ? [ + { + label: 'apostrophe:duplicate', + action: 'copy' + } + ] : []), + ...(this.canLocalize ? [ + { + label: 'apostrophe:localize', + action: 'localize' + } + ] : []), + ...this.customMenusByContext, + ...((this.showDiscardDraft && this.canDiscardDraft) ? [ + { + label: this.context.lastPublishedAt ? 'apostrophe:discardDraft' : 'apostrophe:deleteDraft', + action: 'discardDraft', + modifiers: [ 'danger' ] + } + ] : []), + ...(this.showArchive && this.canArchive ? [ + { + label: 'apostrophe:archive', + action: 'archive', + modifiers: [ 'danger' ] + } + ] : []), + ...(this.canUnpublish ? [ + { + label: 'apostrophe:unpublish', + action: 'unpublish', + modifiers: [ 'danger' ] + } + ] : []), + ...(this.showRestore && this.canRestore ? [ + { + label: 'apostrophe:restore', + action: 'restore' + } + ] : []) + ]; + return menu; + }, + customMenusByContext() { + if (!this.canEdit) { + return []; + } + + const menus = this.customOperationsByContext + .map(op => ({ + label: op.label, + action: op.action, + modifiers: op.modifiers || [] + })); + menus.sort((a, b) => a.modifiers.length - b.modifiers.length); + return menus; + }, + customOperationsByContext() { + return this.customOperations.filter(({ + manuallyPublished, hasUrl, conditions, context, if: ifProps + }) => { + if (typeof manuallyPublished === 'boolean' && manuallyPublished !== this.manuallyPublished) { + return false; + } + + if (typeof hasUrl === 'boolean' && hasUrl !== this.hasUrl) { + return false; + } + + if (conditions) { + const notAllowed = conditions.some((action) => !this[action]); + + if (notAllowed) { + return false; + } + } + + if (ifProps) { + const canSeeOperation = checkIfConditions(this.doc, ifProps); + + if (!canSeeOperation) { + return false; + } + } + + return context === 'update' && this.isUpdateOperation; + }); + }, + moduleName() { + if (apos.modules[this.context.type].action === apos.modules['@apostrophecms/page'].action) { + return '@apostrophecms/page'; + } else { + return this.context.type; + } + }, + moduleOptions() { + return apos.modules[this.moduleName]; + }, + isUpdateOperation() { + return !!this.context._id; + }, + hasUrl() { + return !!this.context._url; + }, + canPublish() { + if (this.context._id) { + return this.context._publish; + } else { + return this.moduleOptions.canPublish; + } + }, + canEdit() { + if (this.context._id) { + return this.context._edit; + } else { + return true; + } + }, + canDismissSubmission() { + return this.canEdit && this.context.submitted && (this.canPublish || (this.context.submitted.byId === apos.login.user._id)); + }, + canDiscardDraft() { + if (!this.manuallyPublished) { + return false; + } + if (!this.context._id) { + return false; + } + if (!this.canEdit) { + return false; + } + return ( + (!this.context.lastPublishedAt) && + !this.moduleOptions.singleton + ) || ( + this.context.lastPublishedAt && + this.isModifiedFromPublished + ); + }, + canLocalize() { + return this.moduleOptions.canLocalize && + this.context._id; + }, + canArchive() { + return ( + this.canEdit && + this.context._id && + !this.moduleOptions.singleton && + !this.context.archived && + !this.context.parked && + ((this.moduleOptions.canPublish && this.context.lastPublishedAt) || !this.manuallyPublished) + ); + }, + canUnpublish() { + return ( + this.canEdit && + !this.context.parked && + this.moduleOptions.canPublish && + this.context.lastPublishedAt && + this.manuallyPublished + ); + }, + canCopy() { + return this.canEdit && + this.moduleOptions.canEdit && + !this.moduleOptions.singleton && + this.context._id; + }, + canRestore() { + return ( + this.canEdit && + this.context._id && + this.context.archived && + ((this.moduleOptions.canPublish && this.context.lastPublishedAt) || !this.manuallyPublished) + ); + }, + manuallyPublished() { + return this.moduleOptions.localized && !this.autopublish; + }, + autopublish() { + return this.context._aposAutopublish ?? this.moduleOptions.autopublish; + }, + isModified() { + if (!this.current) { + return false; + } + return detectDocChange(this.schema, this.context, this.current); + }, + isModifiedFromPublished() { + if (this.context.modified) { + // In a list context, we won't have every area property to + // compare, but we will have this previously set flag + return true; + } + if (!this.context.lastPublishedAt) { + return false; + } + if (!this.published) { + return false; + } + const result = detectDocChange(this.schema, this.published, this.context); + return result; + }, + schema() { + // moduleOptions gives us the action, etc. but here we need the schema + // which is always type specific, even for pages so get it ourselves + let schema = (apos.modules[this.context.type].schema || []).filter(field => apos.schema.components.fields[field.type]); + // Archive UI is handled via action buttons + schema = schema.filter(field => field.name !== 'archived'); + return schema; + } + }, + watch: { + doc() { + this.context = this.doc; + } + }, + mounted() { + apos.bus.$on('content-changed', this.onContentChanged); + apos.bus.$on('command-menu-admin-bar-discard-draft', this.customDiscardDraft); + }, + destroyed() { + apos.bus.$off('content-changed', this.onContentChanged); + apos.bus.$off('command-menu-admin-bar-discard-draft', this.customDiscardDraft); + }, + methods: { + customDiscardDraft() { + if (this.showDiscardDraft && this.canDiscardDraft) { + this.menuHandler('discardDraft'); + } + }, + async onContentChanged(e) { + if (e.doc && (e.doc._id === this.context._id)) { + this.context = e.doc; + } else if (e.docIds && e.docIds.includes(this.context._id)) { + try { + this.context = await apos.http.get(`${this.moduleOptions.action}/${this.context._id}`, { + busy: true + }); + } catch (error) { + // If not found it is likely that there was an archiving or restoring + // batch operation. + if (error.name !== 'notfound') { + console.error(error); + } + } + } + }, + menuHandler(action) { + const operation = this.customOperations.find(op => op.action === action); + if (operation) { + this.customAction(this.context, operation); + return; + } + this[action](this.context); + }, + async edit(doc) { + await apos.modal.execute(doc._aposEditorModal || this.moduleOptions.components.editorModal, { + moduleName: this.moduleName, + docId: doc._id, + type: doc.type + }); + }, + preview(doc) { + window.open(doc._url, '_blank').focus(); + }, + async copy(doc) { + // If there are changes warn the user before discarding them before + // the copy operation + if (this.current) { + if (!await this.confirmAndCancel()) { + return; + } else { + this.$emit('close', doc); + } + } + + await apos.modal.execute(doc._aposEditorModal || this.moduleOptions.components.editorModal, { + moduleName: this.moduleName, + copyOfId: doc._id, + // Passed for bc + copyOf: { + ...this.current || doc, + _id: doc._id + }, + type: doc.type + }); + + }, + async customAction(doc, operation) { + const props = { + moduleName: operation.moduleName || this.moduleName, + // For backwards compatibility + doc, + ...docProps(doc), + ...operation.props + }; + await apos.modal.execute(operation.modal, props); + function docProps(doc) { + return Object.fromEntries(Object.entries(operation.docProps || {}).map(([ key, value ]) => { + return [ key, doc[value] ]; + })); + } + }, + async localize(doc) { + // If there are changes warn the user before discarding them before + // the localize operation + if (this.current) { + if (!await this.confirmAndCancel()) { + return; + } else { + this.$emit('close', doc); + } + } + apos.bus.$emit('admin-menu-click', { + itemName: '@apostrophecms/i18n:localize', + props: { + doc + } + }); + }, + close() { + this.$emit('close', this.doc); + } + } +};