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')