UNPKG

apostrophe

Version:
803 lines (735 loc) • 30.3 kB
// 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); } } } } }; } };