UNPKG

@silexlabs/grapesjs-data-source

Version:
242 lines (216 loc) 7.72 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 { Component } from 'grapesjs' import { Expression, StoredToken, State, Property, Filter, DataSourceId, IDataSource, FIXED_TOKEN_ID } from '../types' import { DataSourceManagerState } from './dataSourceManager' import { getParentByPersistentId, getState } from './state' import { fromStored } from './token' import { toExpression } from '../utils' /** * Expression evaluation context */ export interface EvaluationContext { readonly dataSources: readonly IDataSource[] readonly filters: readonly Filter[] readonly previewData: Record<DataSourceId, unknown> readonly component: Component readonly resolvePreviewIndex: boolean } /** * Handle preview index for array data */ export function handlePreviewIndex(value: unknown, token: StoredToken): unknown { if (typeof token.previewIndex === 'undefined') { return value } if (Array.isArray(value)) { return value[token.previewIndex] } return value } /** * Evaluate a property token */ export function evaluatePropertyToken( token: Property, remaining: Expression, context: EvaluationContext, prevValues: unknown ): unknown { // Handle "fixed" property (hard coded string set by the user) if (token.fieldId === FIXED_TOKEN_ID) { return evaluateExpressionTokens(remaining, context, token.options?.value) } // Get data object let prevObj if (typeof prevValues === 'undefined' || prevValues === null) { if (!token.dataSourceId) { throw new Error(`Data source ID is missing for token: ${JSON.stringify(token)}`) } prevObj = context.previewData[token.dataSourceId] } else { prevObj = prevValues } // Get the next value let value = prevObj ? (prevObj as Record<string, unknown>)[token.fieldId] : null // Handle preview index if resolvePreviewIndex is true // Don't resolve if next token is a filter - filters need to operate on full arrays if (context.resolvePreviewIndex) { const nextToken = remaining[0] if (!nextToken || nextToken.type !== 'filter') { value = handlePreviewIndex(value, token) } } // For non-final tokens, handle preview index only if next token is not a filter // Filters need to operate on full arrays, not individual items if (remaining.length > 0 && !context.resolvePreviewIndex) { const nextToken = remaining[0] if (nextToken.type !== 'filter') { value = handlePreviewIndex(value, token) } } // Special handling for items state // @ts-expect-error - Runtime property check for items handling if (token.isItems && typeof token.previewIndex !== 'undefined') { if (remaining.length > 0) { value = [value] } } return evaluateExpressionTokens(remaining, context, value) } /** * Evaluate expression tokens recursively */ export function evaluateExpressionTokens( expression: Expression, context: EvaluationContext, prevValues: unknown = null, ): unknown { if (expression.length === 0) { return prevValues } // Always create defensive copies of tokens to prevent mutations from affecting original data const cleanExpression = expression.map(token => ({ ...token })) const [token, ...rest] = cleanExpression switch (token.type) { case 'state': { return evaluateStateToken(token as State, rest, context, prevValues) } case 'property': { return evaluatePropertyToken(token as Property, rest, context, prevValues) } case 'filter': { return evaluateFilterToken(token as Filter, rest, context, prevValues) } default: throw new Error(`Unsupported token type: ${JSON.stringify(token)}`) } } /** * Evaluate a state token */ export function evaluateStateToken( state: State, remaining: Expression, context: EvaluationContext, prevValues: unknown ): unknown { const resolvedExpression = resolveStateExpression(state, context.component, context) if (!resolvedExpression) { throw new Error(`Unable to resolve state: ${JSON.stringify(state)}`) } // Special handling for items state - always wrap result in array when resolvePreviewIndex is true const previewIndex = resolvedExpression[resolvedExpression.length - 1]?.previewIndex if (state.storedStateId === 'items' && typeof previewIndex !== 'undefined') { // @ts-expect-error - Adding runtime property for items state handling resolvedExpression[0].isItems = true } return evaluateExpressionTokens([...resolvedExpression, ...remaining], context, prevValues) } /** * Resolve a state token to its expression */ export function resolveStateExpression( state: State, component: Component, context: DataSourceManagerState | EvaluationContext | readonly IDataSource[] ): Expression | null { const parent = getParentByPersistentId(state.componentId, component) if (!parent) { console.error('Component not found for state', state, component.get('id-plugin-data-source')) return null } // Get the expression of the state const storedState = getState(parent, state.storedStateId, state.exposed) if (!storedState?.expression) { console.warn('State is not defined on component', parent.getId(), state, storedState) return null } // Create a minimal DataTree-like object for fromStored compatibility return storedState.expression .flatMap((token: StoredToken) => { switch (token.type) { case 'state': { return resolveStateExpression(fromStored(token, component.getId()), parent, context) ?? [] } default: return token } }) } /** * Evaluate a filter token (Liquid filter) */ export function evaluateFilterToken( token: Filter, remaining: Expression, context: EvaluationContext, prevValues: unknown ): unknown { const options = Object.entries(token.options).reduce((acc, [key, value]) => { // If value is a primitive (number, string, boolean), use it directly // Only evaluate as expression if it's an actual expression object/array const expression = toExpression(value) acc[key] = expression ? evaluateExpressionTokens(expression, context, null) : value return acc }, {} as Record<string, unknown>) const filter = context.filters.find(f => f.id === token.id) if (!filter) { throw new Error(`Filter not found: ${token.id}`) } let value try { value = filter.apply(prevValues, options) } catch (e) { console.warn(`Filter "${filter.id}" error:`, e, { filter: filter.id, prevValues, options, valueType: typeof prevValues, isArray: Array.isArray(prevValues), isNull: prevValues === null, }) // Mimic behavior of liquid - return null on error return null } // Don't resolve before filters - they need arrays const nextIsFilter = remaining.length > 0 && remaining[0]?.type === 'filter' if (!nextIsFilter && (context.resolvePreviewIndex || remaining.length > 0)) { value = handlePreviewIndex(value, token) } return evaluateExpressionTokens(remaining, context, value) }