apostrophe
Version:
The Apostrophe Content Management System.
610 lines (569 loc) • 18.5 kB
JavaScript
// 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([]);
}
}
};