diff --git a/src/demo/index.html b/src/demo/index.html index 3bb7a8a4..4808b5b0 100644 --- a/src/demo/index.html +++ b/src/demo/index.html @@ -34,10 +34,8 @@
-
-
-

Rendered Form

-
+
+
diff --git a/src/lib/js/components/autocomplete.mjs b/src/lib/js/components/autocomplete.mjs index a0862eb9..6e149814 100644 --- a/src/lib/js/components/autocomplete.mjs +++ b/src/lib/js/components/autocomplete.mjs @@ -27,10 +27,10 @@ export const labelCount = (arr, label) => { * @return {String} component label */ const getComponentLabel = ({ name, id, ...component }) => { - const labelPaths = ['config.label', 'attrs.id', 'meta.id'] + const labelPaths = ['config.label', 'config.controlId', 'meta.id', 'attrs.id'] const label = labelPaths.reduce((acc, cur) => { if (!acc) { - acc = component.get(cur) + return component.get(cur) } return acc }, null) diff --git a/src/lib/js/components/component-data.js b/src/lib/js/components/component-data.js index a82f9788..1e7f0978 100644 --- a/src/lib/js/components/component-data.js +++ b/src/lib/js/components/component-data.js @@ -3,17 +3,30 @@ import { uuid, clone, merge } from '../common/utils/index.mjs' import { get } from '../common/utils/object.mjs' export default class ComponentData extends Data { - load = (data = Object.create(null)) => { + load = dataArg => { + const data = this.parseformData(dataArg) this.empty() - if (typeof data === 'string') { - data = JSON.parse(data) + for (const [key, val] of Object.entries(data)) { + this.add(key, val) } - Object.entries(data).forEach(([key, val]) => this.add(key, val)) return this.data } + /** + * Retrieves data from the specified path or adds new data if no path is provided. + * + * @param {string} [path] - The path to retrieve data from. If not provided, new data will be added. + * @returns {*} The data retrieved from the specified path or the result of adding new data. + */ get = path => (path ? get(this.data, path) : this.add()) + /** + * Adds a new component with the given id and data. + * + * @param {string} id - The unique identifier for the component. If not provided, a new UUID will be generated. + * @param {Object} [data=Object.create(null)] - The data to initialize the component with. + * @returns {Object} The newly created component. + */ add = (id, data = Object.create(null)) => { const elemId = id || uuid() const component = this.Component({ ...data, id: elemId }) @@ -29,9 +42,9 @@ export default class ComponentData extends Data { */ remove = componentId => { if (Array.isArray(componentId)) { - componentId.forEach(id => { + for (const id of componentId) { this.get(id).remove() - }) + } } else { this.get(componentId).remove() } @@ -39,6 +52,12 @@ export default class ComponentData extends Data { return this.data } + /** + * Deletes a component from the data object. + * + * @param {string} componentId - The ID of the component to delete. + * @returns {string} The ID of the deleted component. + */ delete = componentId => { delete this.data[componentId] return componentId diff --git a/src/lib/js/components/component.js b/src/lib/js/components/component.js index 137875f9..d8237afa 100644 --- a/src/lib/js/components/component.js +++ b/src/lib/js/components/component.js @@ -16,7 +16,7 @@ import Components from './index.js' import Data from './data.js' import animate from '../common/animation.js' import Controls from './controls/index.js' -import { get } from '../common/utils/object.mjs' +import { get, set } from '../common/utils/object.mjs' import { toTitleCase } from '../common/utils/string.mjs' export default class Component extends Data { @@ -355,6 +355,7 @@ export default class Component extends Data { /** * Method for handling onAdd for all components + * @todo improve readability of this method * @param {Object} evt * @return {Object} Component */ @@ -397,10 +398,14 @@ export default class Component extends Data { const onAddConditions = { controls: () => { - const { controlData } = Controls.get(item.id) const { - meta: { id: metaId }, - } = controlData + controlData: { + meta: { id: metaId }, + ...elementData + }, + } = Controls.get(item.id) + + set(elementData, 'config.controlId', metaId) const controlType = metaId.startsWith('layout-') ? metaId.replace(/^layout-/, '') : 'field' const targets = { @@ -424,7 +429,7 @@ export default class Component extends Data { const depth = get(targets, `${this.name}.${controlType}`) const action = depthMap.get(depth)() dom.remove(item) - const component = action(controlData, newIndex) + const component = action(elementData, newIndex) return component }, @@ -501,10 +506,23 @@ export default class Component extends Data { events.onRender && dom.onRender(this.dom, events.onRender) } + /** + * Sets the configuration for the component. See src/demo/js/options/config.js for example + * @param {Object} config - Configuration object with possible structures: + * @param {Object} [config.all] - Global configuration applied to all components + * @param {Object} [config[controlId]] - Configuration specific to a control type + * @param {Object} [config[id]] - Configuration specific to a component instance + * @description Merges configurations in order of precedence: + * 1. Existing config (this.configVal) + * 2. Global config (all) + * 3. Control type specific config + * 4. Instance specific config + * The merged result is stored in this.configVal + */ set config(config) { - const metaId = get(this.data, 'meta.id') const allConfig = get(config, 'all') - const typeConfig = metaId && get(config, metaId) + const controlId = get(this.data, 'config.controlId') + const typeConfig = controlId && get(config, controlId) const idConfig = get(config, this.id) const mergedConfig = [allConfig, typeConfig, idConfig].reduce( (acc, cur) => (cur ? merge(acc, cur) : acc), diff --git a/src/lib/js/components/controls/index.js b/src/lib/js/components/controls/index.js index 2817062f..d3834151 100644 --- a/src/lib/js/components/controls/index.js +++ b/src/lib/js/components/controls/index.js @@ -16,7 +16,7 @@ import layoutControls from './layout/index.js' import formControls from './form/index.js' import htmlControls from './html/index.js' import defaultOptions from './options.js' -import { get } from '../../common/utils/object.mjs' +import { get, set } from '../../common/utils/object.mjs' const defaultElements = [...formControls, ...htmlControls, ...layoutControls] @@ -126,17 +126,17 @@ export class Controls { */ groupConfig.content = elements.filter(control => { const { controlData: field } = this.get(control.id) - const fieldId = field.meta.id || '' + const controlId = field.meta.id || '' const filters = [ - match(fieldId, this.options.disable.elements), + match(controlId, this.options.disable.elements), field.meta.group === group.id, - !usedElementIds.includes(field.meta.id), + !usedElementIds.includes(controlId), ] let shouldFilter = true shouldFilter = filters.every(val => val === true) if (shouldFilter) { - usedElementIds.push(fieldId) + usedElementIds.push(controlId) } return shouldFilter @@ -349,24 +349,29 @@ export class Controls { return element } + layoutTypes = { + row: () => Stages.active.addChild(), + column: () => this.layoutTypes.row().addChild(), + field: controlData => this.layoutTypes.column().addChild(controlData), + } + /** * Append an element to the stage * @param {String} id of elements */ addElement = id => { - const controlData = get(this.get(id), 'controlData') - const { meta: { group, id: metaId }, - } = controlData + ...elementData + } = get(this.get(id), 'controlData') + + set(elementData, 'config.controlId', metaId) - const layoutTypes = { - row: () => Stages.active.addChild(), - column: () => layoutTypes.row().addChild(), - field: controlData => layoutTypes.column().addChild(controlData), + if (group === 'layout') { + return this.layoutTypes[metaId.replace('layout-', '')]() } - return group !== 'layout' ? layoutTypes.field(controlData) : layoutTypes[metaId.replace('layout-', '')]() + return this.layoutTypes.field(elementData) } applyOptions = async (controlOptions = {}) => { diff --git a/src/lib/js/components/data.js b/src/lib/js/components/data.js index 4abda6ab..eb0e7ed9 100644 --- a/src/lib/js/components/data.js +++ b/src/lib/js/components/data.js @@ -100,4 +100,25 @@ export default class Data { } setCallbacks = {} configVal = Object.create(null) + + /** + * Parses the provided data argument. If the argument is a string, it attempts to parse it as JSON. + * If the parsing fails, it logs an error and returns an empty object. + * If the argument is not a string, it returns the argument as is. + * + * @param {string|Object} dataArg - The data to be parsed. Can be a JSON string or an object. + * @returns {Object} - The parsed object or the original object if the input was not a string. + */ + parseformData = (dataArg = Object.create(null)) => { + if (typeof dataArg === 'string') { + try { + return JSON.parse(dataArg) + } catch (e) { + console.error('Invalid JSON string provided:', e) + return Object.create(null) + } + } + + return dataArg + } } diff --git a/src/lib/js/components/fields/edit-panel.js b/src/lib/js/components/fields/edit-panel.js index 0b5973aa..0fd3a4bb 100644 --- a/src/lib/js/components/fields/edit-panel.js +++ b/src/lib/js/components/fields/edit-panel.js @@ -126,7 +126,8 @@ export default class EditPanel { * @param {String} attr * @param {String|Array} val */ - addAttribute = (attr, val) => { + addAttribute = (attr, valArg) => { + let val = valArg const safeAttr = slugify(attr) const itemKey = `attrs.${safeAttr}` @@ -157,16 +158,16 @@ export default class EditPanel { * Add option to options panel */ addOption = () => { - const metaId = this.field.data.meta.id + const controlId = this.field.data.config.controlId const fieldOptionData = this.field.get('options') - const type = metaId === 'select' ? 'option' : metaId + const type = controlId === 'select' ? 'option' : controlId const newOptionLabel = i18n.get('newOptionLabel', { type }) || 'New Option' const itemKey = `options.${this.data.length}` - + const lastOptionData = fieldOptionData[fieldOptionData.length - 1] const optionTemplate = fieldOptionData.length ? lastOptionData : {} const itemData = { ...optionTemplate, label: newOptionLabel } - if (metaId !== 'button') { + if (controlId !== 'button') { itemData.value = slugify(newOptionLabel) } const newOption = new EditPanelItem({ diff --git a/src/lib/js/components/fields/field.js b/src/lib/js/components/fields/field.js index 74df7963..b090299e 100644 --- a/src/lib/js/components/fields/field.js +++ b/src/lib/js/components/fields/field.js @@ -65,7 +65,7 @@ export default class Field extends Component { const hideLabel = !!this.get('config.hideLabel') if (hideLabel) { - return + return null } const labelVal = this.get('config.editorLabel') || this.get('config.label') @@ -308,7 +308,7 @@ export default class Field extends Component { */ fieldPreview() { const prevData = clone(this.data) - const { action = {} } = controls.get(prevData.meta.id) + const { action = {} } = controls.get(prevData.config.controlId) prevData.id = `prev-${this.id}` prevData.action = action diff --git a/src/lib/js/components/fields/index.js b/src/lib/js/components/fields/index.js index 3e51039d..3976c30c 100644 --- a/src/lib/js/components/fields/index.js +++ b/src/lib/js/components/fields/index.js @@ -1,7 +1,7 @@ import ComponentData from '../component-data.js' import Field from './field.js' import Controls from '../controls/index.js' -import { get } from '../../common/utils/object.mjs' +import { get, set } from '../../common/utils/object.mjs' const DEFAULT_CONFIG = { actionButtons: { @@ -60,6 +60,23 @@ export class Fields extends ComponentData { return acc }, {}) } + + load = (dataArg = Object.create(null)) => { + const allFieldData = this.parseformData(dataArg) + this.empty() + + for (const [key, val] of Object.entries(allFieldData)) { + const { meta, ...data } = val + // meta object is only for controls, we want to migrate it out of field data + // we only need the control id to tie actions back to control definitons + if (meta?.id) { + set(data, 'config.controlId', meta?.id) + } + this.add(key, data) + } + + return this.data + } } const fields = new Fields() diff --git a/src/lib/js/renderer.js b/src/lib/js/renderer.js index cb9b0998..522c89a8 100644 --- a/src/lib/js/renderer.js +++ b/src/lib/js/renderer.js @@ -177,15 +177,11 @@ export default class FormeoRenderer { ) } - processFieldsOrig = fieldIds => { - return this.orderChildren('fields', fieldIds).map(({ id, ...field }) => - this.cacheComponent(Object.assign({}, field, { id: this.prefixId(id) })), - ) - } + processFields = fieldIds => + this.orderChildren('fields', fieldIds).map(({ id, ...field }) => { + const controlId = field.config?.controlId || field.meta?.id + const { action = {}, dependencies = {} } = this.elements[controlId] || {} - processFields = fieldIds => { - return this.orderChildren('fields', fieldIds).map(({ id, ...field }) => { - const { action = {}, dependencies = {} } = this.elements[field.meta.id] || {} if (dependencies) { fetchDependencies(dependencies) } @@ -194,7 +190,6 @@ export default class FormeoRenderer { return this.cacheComponent({ ...mergedFieldData, id: this.prefixId(id) }) }) - } get processedData() { return Object.values(this.form.stages).map(stage => { @@ -208,41 +203,51 @@ export default class FormeoRenderer { * Evaulate and execute conditions for fields by creating listeners for input and changes * @return {Array} flattened array of conditions */ + handleComponentCondition = (component, ifRest, thenConditions) => { + const listenerEvent = LISTEN_TYPE_MAP(component) + + if (listenerEvent) { + component.addEventListener( + listenerEvent, + evt => { + if (this.evaluateCondition(ifRest, evt)) { + for (const thenCondition of thenConditions) { + this.execResult(thenCondition, evt) + } + } + }, + false, + ) + } + + // Evaluate conditions on load. + const fakeEvt = { target: component } + if (this.evaluateCondition(ifRest, fakeEvt)) { + for (const thenCondition of thenConditions) { + this.execResult(thenCondition, fakeEvt) + } + } + } + applyConditions = () => { - Object.values(this.components).forEach(({ conditions }) => { + for (const { conditions } of Object.values(this.components)) { if (conditions) { - conditions.forEach((condition, i) => { + for (const condition of conditions) { const { if: ifConditions, then: thenConditions } = condition - ifConditions.forEach(ifCondition => { + for (const ifCondition of ifConditions) { const { source, ...ifRest } = ifCondition if (isAddress(source)) { const components = this.getComponents(source) - - components.forEach(component => { - const listenerEvent = LISTEN_TYPE_MAP(component) - - if (listenerEvent) { - component.addEventListener( - listenerEvent, - evt => - this.evaluateCondition(ifRest, evt) && - thenConditions.forEach(thenCondition => this.execResult(thenCondition, evt)), - false, - ) - } - - // Evaluate conditions on load. - const fakeEvt = { target: component } // We don't have an actual event, mock one. - this.evaluateCondition(ifRest, fakeEvt) && - thenConditions.forEach(thenCondition => this.execResult(thenCondition, fakeEvt)) - }) + for (const component of components) { + this.handleComponentCondition(component, ifRest, thenConditions) + } } - }) - }) + } + } } - }) + } } /** diff --git a/src/lib/sass/_render.scss b/src/lib/sass/_render.scss index bce7d308..d4c26d4b 100644 --- a/src/lib/sass/_render.scss +++ b/src/lib/sass/_render.scss @@ -50,9 +50,6 @@ } } -div.formeo-row-wrap { - padding: mixins.space(2) 0; -} fieldset.formeo-row-wrap { padding: mixins.space(2); } diff --git a/tools/generate-json-schema.ts b/tools/generate-json-schema.ts index d7e92777..c24baa20 100644 --- a/tools/generate-json-schema.ts +++ b/tools/generate-json-schema.ts @@ -27,118 +27,126 @@ const formDataSchema = z .object({ $schema: z.string().regex(/\.json$/), id: z.string().uuid(), - stages: z.record( - z.string().uuid(), - z.object({ - id: z.string().uuid(), - children: z.array(z.string().uuid()), - }), - ), - rows: z.record( - z.string().uuid(), - z.object({ - id: z.string().uuid(), - children: z.array(z.string().uuid()), - className: z.union([z.string(), z.array(z.string())]).optional(), - config: z - .object({ - fieldset: z.boolean().optional(), - legend: z.string().optional(), - inputGroup: z.boolean().optional(), - }) - .optional(), - }), - ), - columns: z.record( - z.string().uuid(), - z.object({ - id: z.string().uuid(), - children: z.array(z.string().uuid()), - className: z.union([z.string(), z.array(z.string())]).optional(), - config: z - .object({ - width: z.string().optional(), - }) - .optional(), - }), - ), - fields: z.record( - z.string().uuid(), - z.object({ - id: z.string().uuid(), - tag: z.string(), - attrs: htmlAttributesSchema.optional(), - config: z - .object({ - label: z.string().optional(), - hideLabel: z.boolean().optional(), - editableContent: z.boolean().optional(), - }) - .catchall(z.any()) - .optional(), - meta: z - .object({ - group: z.string().optional(), - icon: z.string().optional(), - id: z.string().optional(), - }) - .optional(), - content: z.any().optional(), - action: z.object({}).optional(), - options: z - .array( - z - .object({ - label: z.string(), - value: z.string().optional(), - selected: z.boolean().optional(), - checked: z.boolean().optional(), - type: z + stages: z + .record( + z.string().uuid(), + z.object({ + id: z.string().uuid(), + children: z.array(z.string().uuid()), + }), + ) + .describe('Droppable zones for rows, columns and fields'), + rows: z + .record( + z.string().uuid(), + z.object({ + id: z.string().uuid(), + children: z.array(z.string().uuid()), + className: z.union([z.string(), z.array(z.string())]).optional(), + config: z + .object({ + fieldset: z.boolean().optional(), + legend: z.string().optional(), + inputGroup: z.boolean().optional(), + }) + .optional(), + }), + ) + .describe('Droppable zones for columns and fields'), + columns: z + .record( + z.string().uuid(), + z.object({ + id: z.string().uuid(), + children: z.array(z.string().uuid()), + className: z.union([z.string(), z.array(z.string())]).optional(), + config: z + .object({ + width: z.string().optional(), + }) + .optional(), + }), + ) + .describe('Droppable zones for fields'), + fields: z + .record( + z.string().uuid(), + z.object({ + id: z.string().uuid(), + tag: z.string(), + attrs: htmlAttributesSchema.optional(), + config: z + .object({ + label: z.string().optional(), + hideLabel: z.boolean().optional(), + editableContent: z.boolean().optional(), + }) + .catchall(z.any()) + .optional(), + meta: z + .object({ + group: z.string().optional(), + icon: z.string().optional(), + id: z.string().optional(), + }) + .optional(), + content: z.any().optional(), + action: z.object({}).optional(), + options: z + .array( + z + .object({ + label: z.string(), + value: z.string().optional(), + selected: z.boolean().optional(), + checked: z.boolean().optional(), + type: z + .array( + z.object({ + type: z.string(), + label: z.string(), + // value: z.string().optional(), + selected: z.boolean().optional(), + }), + ) + .optional(), + }) + .catchall(z.any()), + ) + .optional(), + conditions: z + .array( + z.object({ + if: z .array( z.object({ - type: z.string(), - label: z.string(), - // value: z.string().optional(), - selected: z.boolean().optional(), + source: z.string().optional(), + sourceProperty: z.string().optional(), + comparison: z.string().optional(), + target: z.string().optional(), + targetProperty: z.string().optional(), }), ) .optional(), - }) - .catchall(z.any()), - ) - .optional(), - conditions: z - .array( - z.object({ - if: z - .array( - z.object({ - source: z.string().optional(), - sourceProperty: z.string().optional(), - comparison: z.string().optional(), - target: z.string().optional(), - targetProperty: z.string().optional(), - }), - ) - .optional(), - // "then" is not a keyword when used as a key in - // an object and required for the schema - // biome-ignore lint/suspicious/noThenProperty: - then: z - .array( - z.object({ - target: z.string().optional(), - targetProperty: z.string().optional(), - assignment: z.string().optional(), - value: z.string().optional(), - }), - ) - .optional(), - }), - ) - .optional(), - }), - ), + // "then" is not a keyword when used as a key in + // an object and required for the schema + // biome-ignore lint/suspicious/noThenProperty: + then: z + .array( + z.object({ + target: z.string().optional(), + targetProperty: z.string().optional(), + assignment: z.string().optional(), + value: z.string().optional(), + }), + ) + .optional(), + }), + ) + .optional(), + }), + ) + .describe('Field and Element definitions'), }) .describe('Schema definition for formData')