UNPKG

@firecms/core

Version:

Awesome Firebase/Firestore-based headless open-source CMS

340 lines (295 loc) 12.9 kB
import jsonLogic from "json-logic-js"; import { AuthController, ConditionContext, EnumValueConfig, JsonLogicRule, PropertyConditions, ResolvedProperty, CMSType, Role } from "../types"; import { getIn } from "@firecms/formex"; let operationsRegistered = false; /** * Register custom JSON Logic operations for FireCMS. * Call this once at app initialization. */ export function registerConditionOperations(): void { if (operationsRegistered) return; // Check if user has a specific role by ID jsonLogic.add_operation("hasRole", function (this: ConditionContext, roleId: string) { return this?.user?.roles?.includes(roleId) ?? false; }); // Check if user has any of the specified roles jsonLogic.add_operation("hasAnyRole", function (this: ConditionContext, roleIds: string[]) { if (!this?.user?.roles || !Array.isArray(roleIds)) return false; return roleIds.some(role => this.user.roles.includes(role)); }); // Check if a timestamp is today jsonLogic.add_operation("isToday", (timestamp: number) => { if (!timestamp) return false; const date = new Date(timestamp); const today = new Date(); return date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate(); }); // Check if a timestamp is in the past jsonLogic.add_operation("isPast", (timestamp: number) => { if (!timestamp) return false; return timestamp < Date.now(); }); // Check if a timestamp is in the future jsonLogic.add_operation("isFuture", (timestamp: number) => { if (!timestamp) return false; return timestamp > Date.now(); }); operationsRegistered = true; } /** * Evaluate a JSON Logic rule against the given context. */ export function evaluateCondition(rule: JsonLogicRule, context: ConditionContext): any { // Ensure operations are registered registerConditionOperations(); return jsonLogic.apply(rule, context); } /** * Convert a value to a format suitable for JSON Logic evaluation. * Specifically handles Date objects by converting them to Unix timestamps. */ function serializeValueForConditions(value: any): any { if (value === null || value === undefined) { return value; } // Handle Date objects if (value instanceof Date) { return value.getTime(); } // Handle Firestore Timestamp-like objects (have toDate or toMillis) if (typeof value?.toMillis === "function") { return value.toMillis(); } if (typeof value?.toDate === "function") { return value.toDate().getTime(); } // Handle arrays recursively if (Array.isArray(value)) { return value.map(serializeValueForConditions); } // Handle plain objects recursively if (typeof value === "object") { const result: Record<string, any> = {}; for (const key of Object.keys(value)) { result[key] = serializeValueForConditions(value[key]); } return result; } return value; } /** * Build a ConditionContext from the current property resolution context. */ export function buildConditionContext(params: { propertyKey?: string; values?: Record<string, any>; previousValues?: Record<string, any>; path: string; entityId?: string; index?: number; authController: AuthController; }): ConditionContext { const { propertyKey, values, previousValues, path, entityId, index, authController } = params; const user = authController.user; const serializedValues = serializeValueForConditions(values ?? {}); const serializedPreviousValues = serializeValueForConditions(previousValues ?? values ?? {}); return { values: serializedValues, previousValues: serializedPreviousValues, propertyValue: propertyKey ? getIn(serializedValues, propertyKey) : undefined, path, entityId, isNew: !entityId, index, user: { uid: user?.uid ?? "", email: user?.email ?? null, displayName: user?.displayName ?? null, photoURL: user?.photoURL ?? null, roles: user?.roles?.map((r: Role) => r.id) ?? [] }, now: Date.now() }; } /** * Apply PropertyConditions to a resolved property, evaluating all JSON Logic rules. */ export function applyPropertyConditions<T extends CMSType>( property: ResolvedProperty<T>, context: ConditionContext ): ResolvedProperty<T> { const { conditions } = property; if (!conditions) return property; let result = { ...property }; // ═══════════════════════════════════════════════════════════════════════ // FIELD STATE CONDITIONS // ═══════════════════════════════════════════════════════════════════════ // Evaluate disabled condition if (conditions.disabled) { const isDisabled = evaluateCondition(conditions.disabled, context); if (isDisabled) { result.disabled = { clearOnDisabled: conditions.clearOnDisabled ?? false, disabledMessage: conditions.disabledMessage, hidden: false }; } } // Evaluate hidden condition if (conditions.hidden) { const isHidden = evaluateCondition(conditions.hidden, context); if (isHidden) { result.disabled = { ...(typeof result.disabled === "object" ? result.disabled : {}), hidden: true, clearOnDisabled: conditions.clearOnDisabled ?? false }; } } // Evaluate readOnly condition if (conditions.readOnly) { const isReadOnly = evaluateCondition(conditions.readOnly, context); if (isReadOnly) { result.readOnly = true; } } // ═══════════════════════════════════════════════════════════════════════ // VALIDATION CONDITIONS // ═══════════════════════════════════════════════════════════════════════ // Evaluate required condition if (conditions.required !== undefined) { const isRequired = evaluateCondition(conditions.required, context); result.validation = { ...result.validation, required: isRequired, requiredMessage: conditions.requiredMessage }; } // ═══════════════════════════════════════════════════════════════════════ // VALUE CONDITIONS // ═══════════════════════════════════════════════════════════════════════ // Apply default value for new entities if (context.isNew && conditions.defaultValue !== undefined) { result.defaultValue = evaluateCondition(conditions.defaultValue, context); } // ═══════════════════════════════════════════════════════════════════════ // ENUM CONDITIONS // ═══════════════════════════════════════════════════════════════════════ if ("enumValues" in result && result.enumValues && (conditions.enumConditions || conditions.allowedEnumValues || conditions.excludedEnumValues)) { (result as any).enumValues = applyEnumConditions( result.enumValues as EnumValueConfig[], conditions, context ); } // ═══════════════════════════════════════════════════════════════════════ // REFERENCE CONDITIONS // ═══════════════════════════════════════════════════════════════════════ if (result.dataType === "reference") { if (conditions.referencePath) { (result as any).path = evaluateCondition(conditions.referencePath, context); } if (conditions.referenceFilter) { (result as any).forceFilter = evaluateCondition(conditions.referenceFilter, context); } } // ═══════════════════════════════════════════════════════════════════════ // ARRAY CONDITIONS // ═══════════════════════════════════════════════════════════════════════ if (result.dataType === "array") { if (conditions.canAddElements !== undefined) { (result as any).canAddElements = evaluateCondition(conditions.canAddElements, context); } if (conditions.sortable !== undefined) { (result as any).sortable = evaluateCondition(conditions.sortable, context); } } return result; } /** * Convert an object with numeric keys back to an array. * Firestore stores arrays as {"0": "a", "1": "b"} to avoid nested arrays. */ function objectToArray(obj: unknown): string[] { if (Array.isArray(obj)) return obj.map(String); if (obj && typeof obj === "object") { const keys = Object.keys(obj); if (keys.length > 0 && keys.every(k => !isNaN(Number(k)))) { return keys .sort((a, b) => Number(a) - Number(b)) .map(k => (obj as Record<string, unknown>)[k]) .filter((v): v is string => typeof v === "string" || typeof v === "number") .map(String); } } return []; } /** * Apply enum-specific conditions to filter and modify enum values. */ function applyEnumConditions( enumValues: EnumValueConfig[], conditions: PropertyConditions, context: ConditionContext ): EnumValueConfig[] { let result = [...enumValues]; // Apply allowedEnumValues filter if (conditions.allowedEnumValues) { const allowed = evaluateCondition(conditions.allowedEnumValues, context); // Handle both array format and object-with-numeric-keys format (Firestore workaround) const allowedArray = objectToArray(allowed); if (allowedArray.length > 0) { result = result.filter(ev => allowedArray.includes(String(ev.id))); } } // Apply excludedEnumValues filter if (conditions.excludedEnumValues) { const excluded = evaluateCondition(conditions.excludedEnumValues, context); // Handle both array format and object-with-numeric-keys format const excludedArray = objectToArray(excluded); if (excludedArray.length > 0) { result = result.filter(ev => !excludedArray.includes(String(ev.id))); } } // Apply individual enum conditions if (conditions.enumConditions) { result = result .map(ev => { const evConditions = conditions.enumConditions?.[ev.id]; if (!evConditions) return ev; // Check hidden condition first if (evConditions.hidden && evaluateCondition(evConditions.hidden, context)) { return null; // Will be filtered out } // Check disabled condition if (evConditions.disabled && evaluateCondition(evConditions.disabled, context)) { return { ...ev, disabled: true }; } return ev; }) .filter((ev): ev is EnumValueConfig => ev !== null); } return result; }