apostrophe
Version:
The Apostrophe Content Management System.
803 lines (735 loc) • 30.3 kB
JavaScript
// The base class for all modules that implement a widget, such as
// [@apostrophecms/rich-text-widget](../@apostrophecms/rich-text-widget/index.html)
// and [@apostrophecms/video-widget](../@apostrophecms/video-widget/index.html).
//
// All widgets have a
// [schema](../../tutorials/getting-started/schema-guide.html). Many
// project-specific modules that extend this module consist entirely of `fields`
// configuration and a `views/widget.html` file.
//
// For more information see the [custom widgets
// tutorial](../../tutorials/getting-started/custom-widget.html).
//
// ## Options
//
// ### `label`
//
// The label of the widget, as seen in menus for adding widgets.
//
// ### `neverLoadSelf`
//
// If true, this widget's `load` method will never recursively invoke
// itself for the same widget type. This option defaults to `true`.
// If you set it to `false`, be aware that you are responsible
// for ensuring that the situation does not lead to an infinite loop.
//
// ### `neverLoad`
//
// If set to an array of widget type names, the load methods of the
// specified widget types will never be recursively invoked by this module's
// `load` method. By default this option is empty. See also `neverLoadSelf`,
// which defaults to `true`, resolving most performance problems.
//
// ### `scene`
//
// If your widget wishes to use Apostrophe features like schemas
// when interacting with *logged-out* users — for instance, to implement
// forms conveniently — you can set the `scene` option to `user`. Any
// page that contains the widget will then load the full javascript and
// stylesheet assets normally reserved for logged-in users. Note that if a page
// relies on AJAX calls to load more content later, the assets will not be
// upgraded. So you may wish to set the `scene` option of the appropriate
// subclass of `@apostrophecms/page-type` or `@apostrophecms/piece-page-type`,
// as well.
//
// ### `defer`
//
// If you set `defer: true` for a widget module, like
// @apostrophecms/image-widget, the relationship to actually fetch the images is
// deferred until the last possible minute, right before the template is
// rendered. This can eliminate some queries and speed up your site when there
// are many separate relationships happening on a page that ultimately result in
// loading images.
//
// If you wish this technique to also be applied to images loaded by content on
// the global doc, you can also set `deferWidgetLoading: true` for the
// `@apostrophecms/global` module. To avoid chicken and egg problems, there is
// still a separate query for all the images from the global doc and all the
// images from everything else, but you won't get more than one of each type.
//
// Setting `defer` to `true` may help performance for any frequently used
// widget type that depends on relationships and has a `load` method that can
// efficiently handle multiple widgets.
//
// If you need access to the results of the relationship in server-side
// JavaScript code, outside of page templates, do not use this feature. Since it
// defers the relationships to the last minute, that information will not be
// available yet in any asynchronous node.js code. It is the last thing to
// happen before the actual page template rendering.
//
// ### `preview`
//
// If true, the image widget is automatically previewed live following changes
// in the editor modal. Should not be combined with `contextual`.
//
// ## Fields
//
// You will need to configure the schema fields for your widget using
// the `fields` section.
//
// The standard options for building
// [schemas](../../tutorials/getting-started/schema-guide.html) are accepted.
// The widget will present a modal dialog box allowing the user to edit these
// fields. They are then available inside `widget.html` as properties of
// `data.widget`.
//
// ## Important templates
//
// You will need to supply a `views/widget.html` template for your module that
// extends this module.
//
// In `views/widget.html`, you can access any schema field as a property
// of `data.widget`. You can also access options passed to the widget as
// `data.options`.
//
// ## More
//
// If your widget requires JavaScript on the browser side, you will want
// to write a player function. TODO: document this 3.x style (lean).
//
// ## Command line tasks
//
// ```
// node app your-widget-module-name-here:list
// ```
// Lists all of the places where this widget is used on the site. This is very
// useful if you are debugging a change and need to test all of the different
// ways a widget has been used, or are wondering if you can safely remove one.
//
// ## Widget operations
//
// Widget operations are buttons that appear in the widget toolbar
// (or in the breadcrumb when the widget is selected) that perform
// actions related to the widget. For instance, the image widget
// provides an "Adjust Image" operation that opens a modal allowing
// you to crop or resize the image.
//
// See the `widgetOperations` configuration of the image widget module
// for an example use of widget operations with "standard" placement.
//
// Widget operations can be restricted to users with a given permission,
// and can be made to appear only when certain conditions are met,
// such as the presence of an image in an image widget.
//
// Widget operations can be placed in the standard toolbar or in the breadcrumb
// area at the top of the editing interface. Breadcrumb operations can also
// include switches and informational items that do not appear as buttons.
//
// Valid properties of a widget operation:
// - `label`: The label of the operation, also used as a tooltip. Required
// for standard placement.
// - `icon`: The icon for the operation, e.g. `image-edit-outline`. Required
// (except for "switch" types).
// - `modal`: The name of the modal component to open when the operation
// is invoked. Required for standard placement.
// - `permission`: An object with `action` and `type` properties
// specifying a permission that the user must have to see this operation.
// - `if`: A standard schema `if` clause specifying conditions that
// must be met in the widget data for this operation to appear.
// - `placement`: Either `standard`, `breadcrumb` or `all`. Defaults to
// `standard`.
// - `type`: Either `info`, `switch`, `menu` or undefined. If `info`, the operation
// appears as a non-interactive informational item in the breadcrumb.
// If `switch`, the operation appears as a toggle switch in the breadcrumb. When
// `menu`, the operation appears as a button that opens an inline modal.
// - `choices`: For `switch` type operations, an array of choices
// with `label` and `value` properties. The value will be set on the widget
// when the user selects that choice.
// - `secondaryLevel`: If true, the operation appears in a secondary level of
// the toolbar, accessed by clicking a "more" icon. This is only supported
// for standard placement.
// - `tooltip`: The tooltip to show on hover.
// - `action`: A valid core (e.g. remove, edit, etc.) or custom action to be
// emitted when the operation is clicked. In effect only if no modal is specified.
// - `rawEvents`: An array of raw DOM events to listen for and emit to the parent
// component. In effect only if no type and an action is specified. See layout
// widget for an example. This option works only for breadcrumb operations.
const _ = require('lodash');
module.exports = {
cascades: [ 'fields', 'styles', 'widgetOperations' ],
options: {
neverLoadSelf: true,
initialModal: true,
// Disable (core) widget actions that are not relevant to this widget type.
// Available operations are 'edit', 'remove',
// 'up', 'down', 'cut', 'copy', 'clone'.
placeholder: false,
placeholderClass: 'apos-placeholder',
// window, one-third, two-thirds, half or full:
width: 'window',
// left or right, or null for no explicit origin (internally set
// to 'right'):
origin: null,
preview: true,
// Set to false to opt out of the automatic styling wrapping of widget output
stylesWrapper: true
},
init(self) {
self.isExplicitOrigin = self.options.origin !== null;
self.options.origin = self.options.origin || 'right';
const badFieldName = Object.keys(self.fields).indexOf('type') !== -1;
if (badFieldName) {
throw new Error(`The ${self.__meta.name} module contains a forbidden field property name: "type".`);
}
self.enableBrowserData();
self.determineBestAssetUrl('preview');
self.template = self.options.template || 'widget';
self.name = self.__meta.name.replace(/-widget$/, '');
if (!self.options.label) {
self.options.label = _.startCase(self.name);
}
self.label = self.options.label;
self.composeSchema();
self.composeWidgetOperations();
self.apos.area.setWidgetManager(self.name, self);
// To avoid infinite loops and/or bad performance, the load method of
// this widget type will never invoke itself recursively unless
// the `loadSelf` option of the module is explicitly set to `true`.
// In addition the `neverLoad` option can be set to provide additional
// widget types that are not to be loaded in a nested way beneath this one
self.neverLoad = [ ...self.options.neverLoad || [] ];
if (self.options.neverLoadSelf) {
self.neverLoad.push(self.name);
self.neverLoad = [ ...new Set(self.neverLoad) ];
}
},
widgetOperations(self, options) {
return {
add: {
nudgeUp: {
label: 'apostrophe:nudgeUp',
icon: 'arrow-up-icon',
tooltip: 'apostrophe:nudgeUp',
nativeAction: 'up',
disabledIfProps: {
first: true
}
},
nudgeDown: {
label: 'apostrophe:nudgeDown',
icon: 'arrow-down-icon',
tooltip: 'apostrophe:nudgeDown',
nativeAction: 'down',
disabledIfProps: {
last: true
}
},
styles: {
label: 'apostrophe:styles',
icon: 'palette-icon',
tooltip: 'apostrophe:stylesWidget',
nativeAction: 'edit-styles'
},
...!options.contextual && {
edit: {
label: 'apostrophe:edit',
icon: 'pencil-icon',
tootip: 'apostrophe:editWidget',
nativeAction: 'edit'
}
},
remove: {
label: 'apostrophe:remove',
icon: 'trash-can-outline-icon',
tooltip: 'apostrophe:delete',
nativeAction: 'remove'
},
cut: {
label: 'apostrophe:cut',
icon: 'content-cut-icon',
nativeAction: 'cut',
secondaryLevel: true
},
copy: {
label: 'apostrophe:copy',
icon: 'content-copy-icon',
nativeAction: 'copy',
secondaryLevel: true
},
clone: {
label: 'apostrophe:duplicate',
icon: 'content-duplicate-icon',
nativeAction: 'clone',
disabledIfProps: {
maxReached: true
},
secondaryLevel: true
}
}
};
},
methods(self) {
return {
addStylesFields() {
if (Object.keys(self.stylesGroups).length) {
throw new Error(
'Widget "' + self.name + '": "styles" do not support groups. ' +
'Please remove "groups" property from the "styles" configuration.'
);
}
const fieldSchema = self.apos.styles.expandStyles(self.styles);
if (Object.keys(fieldSchema).length === 0) {
return;
}
const groupFields = [ ...new Set([
...Object.keys(fieldSchema),
...self.fieldsGroups.styles?.fields || []
]) ];
const stylesGroup = {
label: 'apostrophe:styles',
fields: groupFields
};
// Create a default group if none exist
if (
!Object.keys(self.fieldsGroups).length &&
Object.keys(self.fields).length
) {
self.fieldsGroups.basics = {
label: 'apostrophe:basics',
fields: Object.keys(self.fields)
};
}
self.fieldsGroups.styles = stylesGroup;
self.fields = {
...fieldSchema,
...self.fields
};
},
// Return rendered styles object for a given widget instance.
// This shouldn't be used directly, instead use the
// `apos.styles.prepareWidgetStyles(widgetData)` helper method or
// the corresponding Nunjucks helper `apos.styles.render(widget)`.
// The `styleId` parameter is required to scope the styles
// to the specific widget instance.
// The returned object:
// {
// css: '...', // The complete stylesheet text
// inline: '...', // The inline styles to add to the widget element
// classes: '...' // The classes to add to the widget element
// }
getStylesheet(widget, styleId) {
return self.apos.styles.getWidgetStylesheet(self.schema, widget, {
rootSelector: `#${styleId}`,
subset: self.fieldsGroups.styles?.fields || []
});
},
composeSchema() {
self.addStylesFields();
self.schema = self.apos.schema.compose({
addFields: self.apos.schema.fieldsToArray(`Module ${self.__meta.name}`, self.fields),
arrangeFields: self.apos.schema.groupsToArray(self.fieldsGroups)
}, self);
const forbiddenFields = [
'_id',
'type'
];
_.each(self.schema, function (field) {
if (_.includes(forbiddenFields, field.name)) {
throw new Error('Widget type ' + self.name + ': the field name ' + field.name + ' is forbidden');
}
});
},
composeWidgetOperations() {
self.widgetOperations = Object.entries(self.widgetOperations)
.reduce((acc, [ name, operation ]) => {
self.validateWidgetOperation(name, operation);
const disableOperation = self.disableWidgetOperation(name, operation);
if (disableOperation) {
return acc;
}
return [
...acc,
{
name,
...operation
}
];
}, []);
},
disableWidgetOperation(opName, properties) {
if (opName === 'styles' && !Object.keys(self.styles).length) {
return true;
}
return false;
},
// Returns markup for the widget. Invoked via `{% widget ... %}` in the
// `@apostrophecms/area` module as it iterates over widgets in
// an area. The default behavior is to render the template for the widget,
// which is by default called `widget.html`, passing it `data.widget`
// and `data.options`. The module is accessible as `data.manager`.
//
// async, as are all functions that invoke a nunjucks render in
// Apostrophe 3.x.
async output(req, widget, options, _with) {
req.widgetsBundles = {
...req.widgetsBundles || {},
...self.getWidgetsBundles(`${widget.type}-widget`)
};
let effectiveWidget = widget;
if (widget.aposPlaceholder === true) {
// Do not render widget on preview mode:
if (req.query.aposEdit !== '1') {
return '';
}
effectiveWidget = { ...widget };
self.schema.forEach(field => {
if (field.placeholder !== undefined) {
effectiveWidget[field.name] = field.placeholder;
}
});
}
const markup = await self.render(req, self.template, {
widget: effectiveWidget,
options,
manager: self,
contextOptions: _with,
scene: req.scene
});
const hasStyles = Object.keys(self.styles || {}).length > 0;
if (hasStyles && self.options.stylesWrapper !== false) {
const styles = self.apos.styles.prepareWidgetStyles(widget);
const styleTag = self.apos.styles
.getWidgetElements(styles, { scene: req.scene });
const wrapperAttrs = self.apos.styles.getWidgetAttributes(styles);
return `${styleTag}<div${wrapperAttrs ? ' ' + wrapperAttrs : ''}>${markup}</div>`;
}
return markup;
},
getWidgetsBundles(widgetType) {
const widget = self.apos.modules[widgetType];
if (!widget) {
return {};
}
const { rebundleModules } = self.apos.asset;
const rebundleConfigs = rebundleModules.filter(entry => {
const names = widget.__meta?.chain?.map(c => c.name) ?? [ widgetType ];
return names.includes(entry.name);
});
const metadata = self.apos.asset.hasBuildModule()
? widget.__meta.build
: widget.__meta.webpack;
return Object.entries(metadata || {})
.reduce((acc, [ moduleName, config ]) => {
if (self.apos.asset.hasBuildModule()) {
config = config?.[self.apos.asset.getBuildModuleAlias()];
}
if (!config || !config.bundles) {
return acc;
}
return {
...acc,
...self.apos.asset.transformRebundledFor(
moduleName,
config.bundles,
rebundleConfigs
)
};
}, {});
},
// Load relationships and carry out any other necessary async
// actions for our type of widget, as long as it is
// not forbidden due to the `neverLoad` option or the
// `neverLoadSelf` option (which defaults to `true`).
//
// Also implements the `scene` convenience option
// for upgrading assets delivered to the browser
// to the full set of `user` assets (TODO: are we
// removing this in A3?)
//
// If you are looking to add custom loader behavior for
// your widget type, don't extend this method, extend
// the `load` method which just does the loading work.
// `loadIfSuitable` is responsible for invoking that method
// only after checking for recursion issues. Those guards
// should apply to your code too.
async loadIfSuitable(req, widgets) {
if (self.options.scene) {
req.scene = self.options.scene;
}
if (req.aposNeverLoad[self.name]) {
return;
}
const pushing = self.neverLoad.filter(type => !req.aposNeverLoad[type]);
for (const type of pushing) {
req.aposNeverLoad[type] = true;
}
await self.apos.util.recursionGuard(req, `widget:${self.name}`, async () => {
return self.load(req, widgets);
});
for (const type of pushing) {
// Faster than the delete keyword
req.aposNeverLoad[type] = false;
}
},
// Perform relationships and any other necessary async
// actions for our type of widget.
//
// Override this to perform custom actions not
// specified by your schema, talk to APIs, etc. when a widget
// is present.
//
// Note that an array of widgets is handled in a single call
// as you can sometimes optimize for that case.
// Do not assume there is only one. If you can't optimize it,
// that's OK, just loop over them and handle every one.
async load(req, widgets) {
await self.apos.schema.relate(req, self.schema, widgets, undefined);
// If this is a virtual widget (a widget being edited or previewed in
// the editor), any nested areas, etc. inside it haven't already been
// loaded as part of loading a doc. Do that now by creating a query and
// then feeding it our widgets as if they were docs.
if (!(widgets.length && widgets[0]._virtual)) {
return;
}
// Get a doc query so that we can interpose the widgets as our docs and
// have the normal things happen after the docs have been "loaded," such
// as calling loaders of widgets in areas.
// Shut off relationships because we already did them and the query
// would try to do them again based on `type`, which isn't really a doc
// type.
const query = self.apos.doc.find(req).relationships(false);
// Do everything we'd do if the query had fetched the widgets
// as docs
await query.finalize();
await query.after(widgets);
},
// Sanitize the widget. Invoked when the user has edited a widget on the
// browser side. By default, the `input` object is sanitized via the
// `convert` method of `@apostrophecms/schema`, creating a new `output`
// object so that no information in `input` is blindly trusted.
//
// `options` will receive the widget-level options passed in
// this area, including any `defaultOptions` for the widget type.
//
// Returns a new, sanitized widget object.
//
// Intentionally does not accept `ancestors`, widgets must be independent
// of parent document properties to ensure they can be copied safely
// between documents as long as they accept that widget type, so they
// automatically establish a new context for `following`.
async sanitize(req, input, options, { fetchRelationships = true } = {}) {
const convertOptions = { fetchRelationships };
if (!input || typeof input !== 'object') {
// Do not crash
input = {};
}
// Make sure we get default values for contextual fields so
// `by` doesn't go missing for `@apostrophecms/image-widget`
const output = self.apos.schema.newInstance(self.schema);
output._id = self.apos.launder.id(input._id) || self.apos.util.generateId();
output.metaType = 'widget';
output.type = self.name;
output.aposPlaceholder = self.apos.launder.boolean(input.aposPlaceholder);
if (!output.aposPlaceholder) {
const schema = self.allowedSchema(req);
await self.apos.schema.convert(req, schema, input, output, convertOptions);
}
return output;
},
// Return a new schema containing only fields for which the
// current user has the permission specified by the `editPermission`
// property of the schema field, or there is no
// `editPermission`|`viewPermission` property for the field.
allowedSchema(req) {
return _.filter(self.schema, function (field) {
const canEdit = () => self.apos.permission.can(
req,
field.editPermission.action,
field.editPermission.type
);
const canView = () => self.apos.permission.can(
req,
field.viewPermission.action,
field.viewPermission.type
);
return (!field.editPermission && !field.viewPermission) ||
(field.editPermission && canEdit()) ||
(field.viewPermission && canView()) ||
false;
});
},
addSearchTexts(widget, texts) {
self.apos.schema.indexFields(self.schema, widget, texts);
},
// Return true if this widget should be considered
// empty, for instance it is a rich text widget
// with no text or meaningful formatting so far.
// By default this method returns false, so the
// presence of any widget of this type means
// the area containing it is not considered empty.
isEmpty(widget) {
return false;
},
validateWidgetBreadcrumbOperation(name, operation) {
if (operation.type === 'switch' && operation.choices?.length !== 2) {
throw self.apos.error('invalid',
`widgetOperation "${name}" of type "switch" requires a non-empty choices array and supports only two choices.`
);
}
if (operation.type === 'info' && operation.modal) {
throw self.apos.error('invalid',
`widgetOperation "${name}" of type "info" cannot have a modal property.`
);
}
if (!operation.type && !operation.icon) {
throw self.apos.error('invalid',
`widgetOperation "${name}" requires an icon property.`
);
}
if (typeof operation.rawEvents !== 'undefined' && !Array.isArray(operation.rawEvents)) {
throw self.apos.error('invalid',
`widgetOperation "${name}" rawEvents property must be an array if specified.`
);
}
},
validateWidgetOperation(name, operation) {
if ([ 'breadcrumb', 'all' ].includes(operation.placement)) {
self.validateWidgetBreadcrumbOperation(name, operation);
}
if (operation.type === 'menu' && !operation.modal) {
throw self.apos.error('invalid',
`widgetOperation "${name}" of type "menu" requires a modal property.`
);
}
if (operation.placement === 'breadcrumb') {
return;
}
if (operation.type === 'switch') {
throw self.apos.error('invalid',
`widgetOperation "${name}" of type "switch" is only allowed for breadcrumb placement.`
);
}
if (operation.type === 'info') {
throw self.apos.error('invalid',
`widgetOperation "${name}" of type "info" is only allowed for breadcrumb placement.`
);
}
if (operation.rawEvents) {
throw self.apos.error('invalid',
`widgetOperation "${name}" with rawEvents is only allowed for breadcrumb placement.`
);
}
if (!operation.nativeAction && !operation.action && !operation.modal) {
throw self.apos.error('invalid',
`widgetOperation "${name}" needs either modal, action or nativeAction to be set.`
);
}
if (operation.secondaryLevel !== true && !operation.icon) {
throw self.apos.error('invalid',
`widgetOperation "${name}" requires the icon property at primary level.`
);
}
if (operation.secondaryLevel && !operation.label) {
throw self.apos.error('invalid',
`widgetOperation "${name}" requires the label property at secondary level.`
);
}
},
getAllowedWidgetOperations(req) {
return self.widgetOperations.filter(({ permission }) => {
if (permission?.action && permission?.type) {
return self.apos.permission.can(req, permission.action, permission.type);
}
return true;
});
},
getWidgetOperations(req) {
return self.getAllowedWidgetOperations(req).filter(({ placement }) => {
return !placement || [ 'standard', 'all' ].includes(placement);
});
},
getWidgetBreadcrumbOperations(req) {
return self.getAllowedWidgetOperations(req).filter(({ placement }) => {
return [ 'breadcrumb', 'all' ].includes(placement);
});
},
annotateWidgetForExternalFront(widget, { scene } = {}) {
const hasStyles = Object.keys(self.styles || {}).length > 0;
if (!hasStyles) {
return {
aposStylesWrapper: false,
aposStylesElements: '',
aposStylesAttributes: {}
};
}
const styles = self.apos.styles.prepareWidgetStyles(widget);
return {
aposStylesWrapper: self.options.stylesWrapper,
aposStylesElements: self.apos.styles.getWidgetElements(styles, { scene }),
aposStylesAttributes: self.apos.styles.getWidgetAttributes(styles, {}, {
asObject: true
})
};
}
};
},
extendMethods(self) {
return {
// Set the options to be passed to the browser-side singleton
// corresponding to this module. By default they do not depend on `req`,
// but the availability of that parameter allows subclasses to make
// distinctions based on permissions, etc.
//
// If a `browser` option was configured for the module its properties
// take precedence over the default values passed on here for `name`,
// `label`, `action` (the base URL of the module), `schema` and
// `contextualOnly`.
getBrowserData(_super, req) {
const result = _super(req);
const schema = self.allowedSchema(req);
_.defaults(result, {
name: self.name,
label: self.label,
description: self.options.description,
icon: self.options.icon,
previewIcon: self.options.previewIcon,
previewUrl: self.options.previewUrl,
action: self.action,
schema,
contextual: self.options.contextual,
contextualStyles: Boolean(self.options.contextualStyles),
placeholderClass: self.options.placeholderClass,
className: self.options.className,
stylesFields: Object.keys(self.styles),
components: self.options.components,
width: self.options.width,
origin: self.options.origin,
preview: self.options.preview,
isExplicitOrigin: self.isExplicitOrigin,
widgetOperations: self.getWidgetOperations(req),
widgetBreadcrumbOperations: self.getWidgetBreadcrumbOperations(req)
});
return result;
}
};
},
tasks(self) {
return {
list: {
usage: 'Run this task to list all widgets of this type in the project.\nUseful for testing.',
async task(argv) {
return self.apos.migration.eachWidget({}, iterator);
async function iterator(doc, widget, dotPath) {
if (widget.type === self.name) {
// console.log is actually appropriate for once
// because the purpose of this task is to
// write something to stdout. Should
// not become an apos.util.log call. -Tom
console.log(doc.slug + ':' + dotPath);
}
}
}
}
};
}
};