apostrophe
Version:
The Apostrophe Content Management System.
230 lines (216 loc) • 8 kB
JavaScript
/*
* Provides:
*
* 1. A scaffold for modeling the doc or doc-like object in the editor,
* in the form of the docFields data attribute
* 2. A scaffold for managing server side errors, in the form of the
* serverErrors data attribute and the handleSaveError method
* 3. A scaffold for handling `following` in field definitions, via
* the `followingValues` method
* 4. A scaffold for handling conditional fields, via the
* `conditionalFields` method
*
* This mixin is designed to accommodate extension by components like
* AposDocEditor that split the UI into several AposSchemas.
*/
import { klona } from 'klona';
import {
evaluateExternalConditions, getConditionalFields, getConditionTypesObject
} from 'Modules/@apostrophecms/schema/lib/conditionalFields.js';
export default {
props: {
parentFollowingValues: {
type: Object,
default: null
}
},
data() {
return {
docFields: {
data: {}
},
serverErrors: null,
restoreOnly: false,
readOnly: false,
changed: [],
externalConditionsResults: getConditionTypesObject(),
conditionalFields: getConditionTypesObject()
};
},
computed: {
schema() {
let schema = (this.moduleOptions.schema || [])
.filter(field => apos.schema.components.fields[field.type]);
if (this.restoreOnly || this.readOnly) {
schema = klona(schema);
for (const field of schema) {
field.readOnly = true;
}
}
// Archive UI is handled via action buttons
schema = schema.filter(field => field.name !== 'archived');
return schema;
},
docMeta() {
return this.docFields.data?.aposMeta || {};
}
},
watch: {
docType: {
// Evaluate external conditions found in current page-type's schema
async handler() {
if (this.moduleName === '@apostrophecms/page') {
await this.evaluateExternalConditions();
this.evaluateConditions();
}
}
}
},
methods: {
// Evaluate the external conditions found in each field
// via API calls -made in parallel for performance-
// and store their result for reusability.
async evaluateExternalConditions() {
this.externalConditionsResults = await evaluateExternalConditions(
this.schema,
this.docId || this.docFields?.data?._docId || this.docFields?.data?._id,
this.$t
);
},
// followedByCategory may be falsy (all fields), "other" or "utility". The
// returned object contains properties named for each field in that category
// that follows other fields. For instance if followedBy is "utility" then
// in our default configuration `followingValues` will be:
//
// `{ slug: { title: 'latest title here' } }`
followingValues(followedByCategory, parentOnly = false) {
const fields = this.getFieldsByCategory(followedByCategory);
const followingValues = {};
const parentFollowing = {};
for (const [ key, val ] of Object.entries(this.parentFollowingValues || {})) {
parentFollowing[`<${key}`] = val;
}
if (parentOnly) {
// If we are only interested in the parent following values, return them
return parentFollowing;
}
for (const field of fields) {
if (field.following) {
const following = Array.isArray(field.following)
? field.following
: [ field.following ];
followingValues[field.name] = {};
for (const name of following) {
if (name.startsWith('<')) {
followingValues[field.name][name] = parentFollowing[name];
} else {
followingValues[field.name][name] = this.getFieldValue(name);
}
}
}
}
return followingValues;
},
// Fetch the subset of the schema in the given category, either
// 'utility' or 'other', or the entire schema if followedByCategory
// is falsy
getFieldsByCategory(followedByCategory) {
if (followedByCategory && this.utilityFields) {
return (followedByCategory === 'other')
? this.schema.filter(field => !this.utilityFields.includes(field.name))
: this.schema.filter(field => this.utilityFields.includes(field.name));
} else {
return this.schema;
}
},
// The returned object contains a property for each field that is
// conditional on other fields, `true` if that field's conditions are
// satisfied and `false` if they are not. There will be no properties for
// fields that are not conditional.
//
// Any condition on a field that is itself conditional fails if the second
// field's conditions fail.
//
// If present, followedByCategory must be either "other" or "utility", and
// the returned object will contain properties only for conditional fields
// in that category, although they may be conditional upon fields in either
// category.
getConditionalFields(followedByCategory) {
const values = {
// Append the parent following values without the current doc
// values, so that the parent can be used in conditions
...this.followingValues(followedByCategory, true),
// currentDoc for arrays, docFields for all other editors
...(this.currentDoc ? this.currentDoc.data : this.docFields.data)
};
return getConditionalFields(
this.getFieldsByCategory(followedByCategory),
values,
this.externalConditionsResults
);
},
evaluateConditions() {
this.conditionalFields = this.getConditionalFields();
},
// Overridden by components that split the fields into several AposSchemas
getFieldValue(name) {
return this.docFields.data[name];
},
// Simple parents only have one AposSchema object.
// Complex parents like ApocDocEditor can override
// to return the appropriate ref
getAposSchema(field) {
return this.$refs.schema;
},
// Handle a server-side save error, attaching it to the right field
// in the schema. fallback is a fallback error message, if none is provided
// by the server.
async handleSaveError(e, { fallback }) {
// eslint-disable-next-line no-console
console.error(e);
if (e.body?.data?.errors) {
const serverErrors = {};
let first;
e.body.data.errors.forEach(e => {
first = first || e;
serverErrors[e.path] = e;
});
this.serverErrors = serverErrors;
if (first) {
const field = this.schema.find(field => field.name === first.path);
if (field) {
if ((field.group.name !== 'utility') && (this.switchPane)) {
this.switchPane(field.group.name);
}
// Let pane switching effects settle first
this.$nextTick(() => {
this.getAposSchema(field).scrollFieldIntoView(field.name);
});
}
}
} else {
// As per the new standard, any message in `data.detail` is considered
// a human readable error message. If it is not present, we fall back to
// the message in `body.message` or the fallback.
const bodyMessage = e.body?.data?.detail || e.body?.message;
await apos.notify(bodyMessage || fallback, {
type: 'danger',
icon: 'alert-circle-icon',
dismiss: true
});
}
},
triggerValidate() {
this.triggerValidation = true;
this.$nextTick(() => {
this.triggerValidation = false;
});
},
async postprocess() {
// eslint-disable-next-line no-console
console.warn(
'The function postprocess from AposEditorMixin does not do anything anymore.\nRelationship postprocessing is made at input level in AposInputRelationship and in some cases globally like in AposImageWidget.'
);
}
}
};