@silexlabs/grapesjs-data-source
Version:
Grapesjs Data Source
242 lines (216 loc) • 7.72 kB
text/typescript
/*
* 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)
}