UNPKG

@silexlabs/grapesjs-data-source

Version:
232 lines (213 loc) 8.61 kB
/* * Silex website builder, free/libre no-code tool for makers. * Copyright (c) 2023 lexoyo and Silex Labs foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import Backbone from 'backbone' import { COMPONENT_STATE_CHANGED, DATA_SOURCE_CHANGED, DATA_SOURCE_ERROR, DATA_SOURCE_READY, DataSourceId, Filter, IDataSource, IDataSourceModel, Property, StoredToken } from '../types' import { DataSourceEditor, DataSourceEditorOptions } from '../types' import { DataTree } from './DataTree' import { Component, Page } from 'grapesjs' import { StoredState, onStateChange } from './state' import getLiquidFilters from '../filters/liquid' import getGenericFilters from '../filters/generic' import { getComponentDebug, NOTIFICATION_GROUP } from '../utils' /** * FIXME: Why sometimes the methods of the data source are in the attributes? * @return ds if it has the getTypes method or ds.attributes if it has the getTypes method */ export function getDataSourceClass(ds: IDataSource | { attributes: IDataSource }): IDataSource { const unknownTyped = ds as Record<string, unknown> if(typeof unknownTyped.getTypes === 'function') return ds as IDataSource const unknownAttributes = unknownTyped.attributes as Record<string, unknown> if(typeof unknownAttributes.getTypes === 'function') return unknownTyped.attributes as IDataSource console.error('Data source has no getTypes method', ds) throw new Error('Data source has no getTypes method') } /** * GrapesJs plugin to manage data sources */ export class DataSourceManager extends Backbone.Collection<IDataSourceModel> { protected dataTree: DataTree get filters() { return this.dataTree.filters } set filters(filters: Filter[]) { this.dataTree.filters = filters } constructor(models: IDataSource[], protected editor: DataSourceEditor, protected options: DataSourceEditorOptions) { super(models, options) // Make sure the operations are undoable this.editor.UndoManager.add(this) // Add filters from config const filters = (() => { // Include preset from filters/ if (typeof options.filters === 'string') { return [ ...getGenericFilters(editor), ...getLiquidFilters(editor), ] } else { // Define filters in the options return (options.filters as Filter[]) .flatMap((filter: Partial<Filter> | string): Filter[] => { if (typeof filter === 'string') { switch (filter) { case 'liquid': return getLiquidFilters(editor) case 'generic': return getGenericFilters(editor) default: throw new Error(`Unknown filters ${filter}`) } } else { return [{ ...filter as Partial<Filter>, type: 'filter', } as Filter] } }) .map((filter: Filter)=> ({ ...filter, type: 'filter' })) as Filter[] } })() // Init the data tree this.dataTree = new DataTree(editor, { dataSources: this.models, filters, }) // Update the data tree when the data sources change this.on('add update remove change', () => this.modelChanged()) this.on(DATA_SOURCE_READY, (ds: IDataSource) => this.modelReady(ds)) this.on(DATA_SOURCE_CHANGED, (e: CustomEvent) => this.modelChanged(e)) // Start listening to data sources this.modelChanged() // Relay state changes to the editor onStateChange((state: StoredState | null, component: Component) => this.editor.trigger(COMPONENT_STATE_CHANGED, { state, component })) } /** * Get all data sources */ getAll(): IDataSourceModel[] { return this.models } /** * Forward events from data sources to the editor */ protected dataChangedBinded = this.dataChanged.bind(this) dataChanged(e?: CustomEvent) { this.editor.trigger(DATA_SOURCE_CHANGED, e?.detail) } /** * Forward events from data sources to the editor */ protected dataSourceReadyBinded = this.dataSourceReady.bind(this) dataSourceReady(ds: IDataSource) { this.editor.trigger(DATA_SOURCE_READY, ds) } /** * Forward events from data sources to the editor */ protected dataSourceErrorBinded = this.dataSourceError.bind(this) dataSourceError(message: string, ds: IDataSource) { this.editor.trigger(DATA_SOURCE_ERROR, message, ds) } /** * Listen to data source changes */ modelChanged(e?: CustomEvent) { this.dataTree.dataSources = this.models.map(ds => getDataSourceClass(ds)) as IDataSourceModel[] // Remove all listeners this.models.forEach((dataSource: IDataSourceModel) => { dataSource.off(DATA_SOURCE_READY, this.dataSourceReadyBinded) dataSource.off(DATA_SOURCE_CHANGED, this.dataChangedBinded) dataSource.off(DATA_SOURCE_ERROR, this.dataSourceErrorBinded) }) // Add listeners on all data sources this.models.forEach((dataSource: IDataSourceModel) => { dataSource.on(DATA_SOURCE_READY, this.dataSourceReadyBinded) dataSource.on(DATA_SOURCE_CHANGED, this.dataChangedBinded) dataSource.on(DATA_SOURCE_ERROR, this.dataSourceErrorBinded) }) // Update undo manager this.editor.getModel().handleUpdates(this, this.toJSON()) // Forward the event this.editor.trigger(DATA_SOURCE_CHANGED, e?.detail) } /** * Listen to data source changes */ modelReady(ds: IDataSource) { // Forward the event this.editor.trigger(DATA_SOURCE_READY, ds) } getDataTree() { return this.dataTree } getPageQuery(page: Page): Record<DataSourceId, string> { const expressions = this.dataTree.getPageExpressions(page) return this.models .map(ds => { const dsExpressions = expressions // Resolve all states .map((componentExpression) => ({ component: componentExpression.component, expression: componentExpression.expression.flatMap((token: StoredToken) => { switch(token.type) { case 'property': case 'filter': return token case 'state': { const resolved = this.dataTree.resolveState(token, componentExpression.component) if (!resolved) { this.editor.runCommand('notifications:add', { type: 'error', group: NOTIFICATION_GROUP, message: `Unable to resolve state ${JSON.stringify(token)}. State defined on component ${getComponentDebug(componentExpression.component)}`, componentId: componentExpression.component.getId(), }) throw new Error(`Unable to resolve state ${JSON.stringify(token)}. State defined on component ${getComponentDebug(componentExpression.component)}`) } return resolved } } }) })) // Keep only the expressions for the current data source .filter(componentExpression => { const e = componentExpression.expression if(e.length === 0) return false // We resolved all states // An expression can not start with a filter // So this is a property const first = e[0] as Property // Keep only the expressions for the current data source return first.dataSourceId === ds.id }) const trees = this.dataTree.toTrees(dsExpressions, ds.id) if(trees.length === 0) { return { dataSourceId: ds.id.toString(), query: '', } } const query = getDataSourceClass(ds).getQuery(trees) return { dataSourceId: ds.id.toString(), query, } }) .filter(obj => !!obj.query) .reduce((acc, { dataSourceId, query }) => { acc[dataSourceId] = query return acc }, {} as Record<DataSourceId, string>) } }