UNPKG

apostrophe

Version:
610 lines (569 loc) • 18.5 kB
// Pages manager (tree) modal business logic. import { mapActions } from 'pinia'; import { klona } from 'klona'; import AposModifiedMixin from 'Modules/@apostrophecms/ui/mixins/AposModifiedMixin'; import AposArchiveMixin from 'Modules/@apostrophecms/ui/mixins/AposArchiveMixin'; import AposPublishMixin from 'Modules/@apostrophecms/ui/mixins/AposPublishMixin'; import AposDocsManagerMixin from 'Modules/@apostrophecms/modal/mixins/AposDocsManagerMixin'; import { useModalStore } from 'Modules/@apostrophecms/ui/stores/modal'; import { debounce, asyncTaskQueue } from 'Modules/@apostrophecms/ui/utils'; export default { name: 'AposPagesManager', mixins: [ AposModifiedMixin, AposDocsManagerMixin, AposArchiveMixin, AposPublishMixin ], emits: [ 'archive', 'search', 'modal-result' ], props: { modalData: { type: Object, required: true } }, data() { return { moduleName: '@apostrophecms/page', modal: { active: false, triggerFocusRefresh: 0, type: 'slide', showModal: false, width: 'two-thirds' }, headers: [], holdQueries: false, currentPage: 1, totalPages: 1, pages: [], pagesFlat: [], updateQueue: asyncTaskQueue(), updateQueueIndex: 0, options: { columns: [ { columnHeader: 'apostrophe:pageTitle', property: 'title', cellValue: 'title' }, { name: 'labels', component: 'AposCellLabels' }, { columnHeader: 'apostrophe:lastEdited', property: 'updatedAt', component: 'AposCellLastEdited', cellValue: 'updatedAt' }, { property: 'contextMenu', component: 'AposCellContextMenu' } ] }, queryExtras: { // removed to allow per-document permissions users to see the page tree // viewContext: this.relationshipField ? 'relationship' : 'manage' }, filterValues: {}, filterChoices: {}, allPiecesSelection: { isSelected: false, total: 0 }, localeSwitched: this.modalData.hasContextLocale }; }, computed: { /** * Extends the AposDocsManagerMixin's isModified method to check for * existing updates in the queue. * * @returns {boolean} */ isModified() { // `updateQueueIndex` is a "reactive" hack, // because `hasTasks` is not reactive. if (this.updateQueueIndex > 0 && this.updateQueue.hasTasks()) { return true; } return this.relationshipIsModified(); }, treeOptions() { return { bulkSelect: true, draggable: !this.filterValues.archived, ghostUnpublished: true, max: this.relationshipField.max || null }; }, moduleOptions() { return apos.page; }, moduleLabels() { return { singular: this.moduleOptions.label, plural: this.moduleOptions.pluralLabel }; }, saveRelationshipLabel() { if (this.relationshipField && (this.relationshipField.max === 1)) { return 'apostrophe:selectPage'; } else { return 'apostrophe:selectPages'; } }, items() { if (!this.pagesFlat || !this.headers.length) { return []; } return klona(this.pagesFlat); }, selectAllChoice() { const checkLen = this.checked.length; const rowLen = this.items.length; return checkLen > 0 && checkLen !== rowLen ? { value: 'checked', indeterminate: true } : { value: 'checked' }; }, canCreate() { const page = this.items .find(page => page.aposDocId === this.moduleOptions.page.aposDocId); if (page) { return page._create; } return this.moduleOptions.canCreate; }, checkedTypes() { const types = this.checkedDocs.map(doc => doc.type); return [ ...new Set(types) ]; } }, created() { const DEBOUNCE_TIMEOUT = 500; this.onSearch = debounce(this.search, DEBOUNCE_TIMEOUT); this.moduleOptions.filters.forEach(filter => { this.filterValues[filter.name] = filter.def; if (!filter.choices) { this.queryExtras.choices = this.queryExtras.choices || []; this.queryExtras.choices.push(filter.name); } }); }, async mounted() { this.headers = this.computeHeaders(); // Get the data. This will be more complex in actuality. this.modal.active = true; await this.getPages(); this.getAllPagesTotal(); this.modal.triggerFocusRefresh++; apos.bus.$on('content-changed', this.onContentChanged); apos.bus.$on('command-menu-manager-create-new', this.create); apos.bus.$on('command-menu-manager-close', this.confirmAndCancel); }, unmounted() { apos.bus.$off('content-changed', this.onContentChanged); apos.bus.$off('command-menu-manager-create-new', this.create); apos.bus.$off('command-menu-manager-close', this.confirmAndCancel); }, methods: { ...mapActions(useModalStore, [ 'updateModalData', 'isTopManager' ]), async create() { const doc = await apos.modal.execute(this.moduleOptions.components.editorModal, { moduleName: this.moduleName, filterValues: this.filterValues, hasRelationshipField: !!this.relationshipField }); if (!doc) { // Cancel clicked return; } await this.getPages(); if (this.relationshipField) { doc._fields = doc._fields || {}; // Must push to checked docs or it will try to do it for us // and not include _fields this.checkedDocs.push(doc); this.checked.push(doc._id); } }, onUpdate(page) { // A "reactive" hack, because `hasTasks` is not reactive. this.updateQueueIndex++; this.updateQueue.add(() => this.update(page)) .then(() => { this.updateQueueIndex++; }) .catch(() => { this.updateQueueIndex++; }); }, async update(page) { const body = { _targetId: page.endContext, _position: page.endIndex }; const route = `${this.moduleOptions.action}/${page.changedId}`; let result; try { result = await apos.http.patch(route, { busy: false, body, draft: true }); } catch (error) { await apos.notify(error.body.message || this.$t('apostrophe:treeError'), { type: 'danger', icon: 'alert-circle-icon', dismiss: true, localize: false }); } // Patch returned a list of the modifications of all pages due to moving // the page. We need to update the tree with the changes. // The tree is already optimistically updated with the new position, we // only care to keep the state consistent. if (result.__changed) { try { // await new Promise(resolve => setTimeout(resolve, 3000)); await this.updateTree(result); // This might interrupt the queued tasks, but it's fine. // We have to refresh if the current page is moved and has a new URL // as a consequence. const currentSlug = window.apos.page.page?.slug; const currentId = window.apos.page.page?.aposDocId; const currentPage = this.pagesFlat.find(page => page.aposDocId === currentId); if (currentPage && currentPage.slug !== currentSlug) { location.assign(currentPage._url); } } catch (error) { await apos.notify(error.body.message || this.$t('apostrophe:treeError'), { type: 'danger', icon: 'alert-circle-icon', dismiss: true, localize: false }); // If the update fails, we need to refresh the tree // to avoid inconsistant state. This won't stop the rest of the // tasks in the queue, but will reset the scroll position. A small // price to pay for consistency. await this.getPages(); } } else { await this.getPages(); } if (this.items.some(page => { return (page.aposDocId === window.apos.page.page?.aposDocId) && page.archived; })) { // With the current page gone, we need to move to safe ground location.assign(`${apos.prefix}/`); } }, // Recursively update the tree and the flat list with the changes returned // by the server. The `changes` array contains a list of documents with // only the fields that have changed. Update both draft and published // documents. The current document changes comes only for draft and contains // the new depth, updatedAt, and order. We request a fresh published // document for the current document to update it. The rest of the changes // are only the new order values for both draft and published documents. async updateTree(updated) { const { __changed, ...draft } = updated; const changes = __changed.map(change => { if (change.slug) { return { ...change, _url: `${window.apos.prefix}${change.slug}` }; } return change; }); // Retrieve the published document version, generate a change object for // it. const published = await apos.http.get( `${this.moduleOptions.action}/${draft._id.replace(':draft', ':published')}`, { busy: false } ); const draftChange = changes.find(change => change._id === updated._id); const publishedChanges = Object.keys(draftChange) .reduce((acc, key) => { acc[key] = published[key]; return acc; }, {}); changes.push(publishedChanges); // Quick lookup for the changes const index = changes.reduce((acc, change, currentIndex) => { acc[change._id] = currentIndex; return acc; }, {}); this.pages.forEach(updateNode); for (const [ i, page ] of this.pagesFlat.entries()) { const success = update(page, changes, index); if (success) { this.pagesFlat[i] = { ...page }; } } this.pages = [ ...this.pages ]; this.pagesFlat = [ ...this.pagesFlat ]; function updateNode(node) { update(node, changes, index); if (node._children) { node._children.forEach(updateNode); } } function update(node, changes, index) { // Update the published document if it exists const publishedId = node._publishedDoc?._id; if (publishedId && typeof index[publishedId] !== 'undefined') { Object.assign(node._publishedDoc, changes[index[publishedId]]); node._publishedDoc = { ...node._publishedDoc }; } // Update the draft document if (typeof index[node._id] !== 'undefined') { Object.assign(node, changes[index[node._id]]); // Any changes to the slug has to be applied, no matter // if the changes contain published document changes. // The children changes for published docs are currently // not reported by the server. if (changes[index[node._id]].slug && node._publishedDoc) { node._publishedDoc.slug = node.slug; node._publishedDoc.path = node.path; node._publishedDoc._url = node._url; node._publishedDoc = { ...node._publishedDoc }; } return true; } return false; } }, async request (mergeOptions) { const options = { ...this.filterValues, ...this.queryExtras, ...mergeOptions, archived: this.relationshipField || !this.filterValues.archived ? '0' : 'any', all: '1', withPublished: 1 }; // Avoid undefined properties. const qs = Object.entries(options) .reduce((acc, [ key, val ]) => ({ ...acc, ...val !== undefined && { [key]: val } }), {}); return apos.http.get( this.moduleOptions.action, { qs, busy: true, draft: true } ); }, async getPages () { if (this.holdQueries) { // Avoid race conditions by trying again later if already in progress setTimeout(this.getPages, 100); return; } this.holdQueries = true; const self = this; try { this.pages = []; this.pagesFlat = []; let pageTree = await this.request({ page: this.currentPage }); // If editor is looking at the archive tree, trim the normal page tree // response if (this.filterValues.archived) { pageTree = pageTree._children.find(page => page.slug === '/archive'); pageTree = pageTree._children; } pageTree = filterValidPages(pageTree); formatPage(pageTree); if (!pageTree.length && pageTree.length !== 0) { pageTree = [ pageTree ]; } this.currentPage = 1; this.totalPages = 1; this.pages = [ ...pageTree ]; } finally { this.holdQueries = false; } function formatPage(page) { if (page.length) { page.forEach(formatPage); return; } if (!page._id) { return; } self.pagesFlat.push(klona(page)); if (Array.isArray(page._children)) { page._children.forEach(formatPage); } } function filterValidPages(page) { if (Array.isArray(page)) { return page.map(filterValidPages).filter(p => p._id); } const result = {}; if (!self.moduleOptions.validPageTypes.includes(page.type)) { return result; } Object.assign(result, page); result._children = []; for (const child of page._children || []) { const filteredChild = filterValidPages(child); if (filteredChild._id) { result._children.push(filteredChild); } } return result; } }, getAllPagesTotal () { this.setAllPiecesSelection({ isSelected: false, total: this.items.length }); }, selectAllPieces () { this.setAllPiecesSelection({ isSelected: true, docs: this.items }); }, async search(query) { if (query) { this.queryExtras.autocomplete = query; } else if ('autocomplete' in this.queryExtras) { delete this.queryExtras.autocomplete; } else { return; } this.currentPage = 1; await this.getPages(); }, async filter(filter, value) { if (this.filterValues[filter] === value) { return; } this.filterValues[filter] = value; this.currentPage = 1; await this.getPages(); this.getAllPagesTotal(); this.headers = this.computeHeaders(); this.setCheckedDocs([]); }, computeHeaders() { let headers = this.options.columns || []; if (this.filterValues.archived) { headers = headers.filter(h => h.component !== 'AposCellLabels'); } return headers; }, async editRelationship(item) { const result = await apos.modal.execute('AposRelationshipEditor', { schema: this.relationshipField.schema, title: item.title, modelValue: item._fields }); if (result) { this.checkedDocs = this.checkedDocs.map((doc) => { if (doc._id !== item._id) { return doc; } return { ...doc, _fields: result }; }); } }, setAllPiecesSelection ({ isSelected, total, docs }) { if (typeof isSelected === 'boolean') { this.allPiecesSelection.isSelected = isSelected; } if (typeof total === 'number') { this.allPiecesSelection.total = total; } if (docs) { this.setCheckedDocs(docs); } }, async handleBatchAction({ label, action, requestOptions = {}, messages }) { if (action) { try { await apos.http.post(`${this.moduleOptions.action}/${action}`, { body: { ...requestOptions, _ids: this.checked, messages, type: this.checked.length === 1 ? this.moduleLabels.singular : this.moduleLabels.plural } }); if (action === 'archive') { await this.getPages(); this.getAllPagesTotal(); this.checked = []; } } catch (error) { apos.notify('apostrophe:errorBatchOperationNoti', { interpolate: { operation: label }, type: 'danger' }); // eslint-disable-next-line no-console console.error(error); } } }, setCheckedDocs(checked) { this.checkedDocs = checked.slice(0, this.relationshipField?.max || checked.length); this.checked = this.checkedDocs.map(item => { return item._id; }); }, async onContentChanged({ doc, action, docIds, docTypes }) { const types = this.getContentChangedTypes(doc, docTypes); if (!types.includes(this.moduleName)) { return; } if ( docIds || !doc.aposLocale || doc.aposLocale.split(':')[0] === this.modalData.locale ) { await this.getPages(); this.getAllPagesTotal(); if (action === 'archive') { this.checked = this.checked.filter(checkedId => doc._id !== checkedId); } if (this.relationshipField && (action === 'insert') && this.isTopManager(this)) { const newDocs = [ ...this.checkedDocs, doc ]; const limit = this.relationshipField?.max || newDocs.length; this.setCheckedDocs(newDocs.slice(0, limit)); } } }, async switchLocale({ locale }) { this.updateModalData(this.modalData.id, { locale: locale.name }); this.localeSwitched = locale.name !== apos.i18n.locale; this.currentPage = 1; await this.getPages(); this.getAllPagesTotal(); this.headers = this.computeHeaders(); this.setCheckedDocs([]); } } };