@browser.style/data-entry
Version:
Dynamic data entry form component with JSON schema validation and internationalization support
753 lines (647 loc) • 24.5 kB
JavaScript
import {
attrs,
buttonAttrs,
fetchOptions,
getObjectByPath,
getValueWithFallback,
isEmpty,
processAttributes,
processRenderConfig,
resolveTemplateString,
safeRender,
t,
toCamelCase,
uuid
} from './utility.js';
/* === all === */
export function all(data, schema, instance, root = false, pathPrefix = '', form = null) {
const groupMap = new Map();
const skipItems = new Set();
const groupContent = new Map();
if (root) {
Object.entries(schema.properties).forEach(([key, config]) => {
if (config.render?.group) {
const groupKey = config.render.group;
if (!groupMap.has(groupKey)) {
groupMap.set(groupKey, []);
}
groupMap.get(groupKey).push({key, config});
skipItems.add(key);
}
});
groupMap.forEach((items, groupKey) => {
groupContent.set(groupKey, items.map(({key, config}) => {
const method = config?.render?.method ? toCamelCase(config.render.method) : '';
const renderMethod = instance.getRenderMethod(method);
const label = resolveTemplateString(config.title, data, instance) || '';
const path = pathPrefix === 'DISABLE_PATH' ? '' : key;
const value = getValueWithFallback(data, key, config);
return method ? safeRender(renderMethod, {
label,
value,
attributes: config?.render?.attributes || [],
options: method === 'select' ? fetchOptions(config, instance) : [],
config,
instance,
path,
type: config.type
}) : '';
}).join(''));
});
}
const arrayContent = [];
const fieldsetGroups = [];
const headline = schema.headline ? resolveTemplateString(schema.headline, data, instance) : '';
const renderNav = schema.navigation;
const standardContent = [];
const title = schema.title ? resolveTemplateString(schema.title, data, instance) : '';
let navContent = '';
let schemaIndex = 0;
Object.entries(schema.properties).forEach(([key, config]) => {
if (skipItems.has(key)) return;
const attributes = config?.render?.attributes || [];
const method = config?.render?.method ? toCamelCase(config.render.method) : '';
const renderMethod = instance.getRenderMethod(method);
const label = resolveTemplateString(config.title, data, instance) || 'LABEL';
const options = method === 'select' ? fetchOptions(config, instance) : [];
const path = pathPrefix === 'DISABLE_PATH' ? '' : (pathPrefix ? `${pathPrefix}.${key}` : key);
const value = getValueWithFallback(data, key, config);
if (groupContent.has(key)) {
fieldsetGroups.push({
index: schemaIndex,
content: fieldset({
label,
content: groupContent.get(key),
path: key,
attributes
})
});
} else if (config.type === 'array') {
if (renderNav) {
navContent += `<a href="#section_${path}" part="link">${label}</a>`;
}
const content = method
? safeRender(renderMethod, { label, value, attributes, options, config, instance, path, type: config.type })
: data[key].map((item, index) => all(item, config.items, instance, false, `${path}[${index}]`)).join('');
arrayContent.push({
index: schemaIndex,
content: method ? content : fieldset({ label, content, attributes })
});
} else if (config.type === 'object') {
if (config.render?.method) {
standardContent.push({
index: schemaIndex,
content: safeRender(renderMethod, {
label,
value,
attributes,
options,
config,
instance,
path,
type: config.type
})
});
} else {
const content = all(data[key], config, instance, false, path);
standardContent.push({
index: schemaIndex,
content: fieldset({ label, content, attributes })
});
}
} else {
const content = method
? safeRender(renderMethod, {
label,
value,
attributes,
options,
config,
instance,
path,
type: config.type
})
: '';
standardContent.push({
index: schemaIndex,
content
});
}
schemaIndex++;
});
const getSchemaPosition = (key) => Object.keys(schema.properties).indexOf(key);
// Sort groups by their position in schema
const sortedGroups = fieldsetGroups.sort((a, b) => {
const posA = getSchemaPosition(Object.keys(schema.properties)[a.index]);
const posB = getSchemaPosition(Object.keys(schema.properties)[b.index]);
return posA - posB;
}).map(item => item.content);
// Sort arrays by their position in schema
const sortedArrays = arrayContent.sort((a, b) => {
const posA = getSchemaPosition(Object.keys(schema.properties)[a.index]);
const posB = getSchemaPosition(Object.keys(schema.properties)[b.index]);
return posA - posB;
}).map(item => item.content);
// Standard content remains sorted by appearance order
const sortedStandard = standardContent.sort((a, b) => a.index - b.index).map(item => item.content);
const rootFieldset = root && sortedStandard.length
? `<fieldset part="fieldset" id="section_root">${title ? `<legend part="legend">${title}</legend>` : ''}${sortedStandard.join('')}</fieldset>`
: sortedStandard.join('');
const innerContent = `${rootFieldset}${sortedArrays.join('')}${sortedGroups.join('')}`;
if (form || root) {
const navElement = renderNav ? `<nav part="${renderNav}">${title ? `<a href="#section_root" part="link">${title}</a>` : ''}${navContent}</nav>` : '';
const headlineElement = headline ? `<strong part="title">${headline}</strong>` : '';
let footerContent = `<snack-bar></snack-bar>`;
if (schema.form) {
if (schema.form.action) form.setAttribute('action', schema.form.action);
if (schema.form.method) form.setAttribute('method', schema.form.method);
if (schema.form.enctype) {
const formEnctype = schema.form.enctype === 'json' ? 'application/json'
: schema.form.enctype === 'form' ? 'multipart/form-data'
: schema.form.enctype;
form.setAttribute('enctype', formEnctype);
}
if (schema.form.autoSave !== undefined) {
form.setAttribute('data-auto-save', schema.form.autoSave);
}
const buttonsHTML = schema.form.buttons?.map(entry =>
`<button type="${entry.type || 'button'}" part="button" ${buttonAttrs(entry)}>${
resolveTemplateString(entry.label, data, instance)
}</button>`
).join('');
if (buttonsHTML) footerContent += `<nav part="nav">${buttonsHTML}</nav>`;
}
const rootContent = `${navElement}${headlineElement}<div part="main">${innerContent}</div><footer part="footer">${footerContent}</footer>`;
if (form) {
form.innerHTML = rootContent;
return;
}
return rootContent;
}
return innerContent;
}
/* === arrayCheckbox === */
export const arrayCheckbox = (params) =>
renderArray({
...params,
renderItem: ({ value, config, path }) => {
const valuePath = config.render?.value || '';
const checked = valuePath.includes('[') || valuePath.includes('.')
? getObjectByPath(value, valuePath)
: valuePath ? value[valuePath] : false;
const rowLabel = config.render?.label ? value[config.render.label] || config.render.label : 'LABEL';
const fullPath = valuePath ? `${path}.${valuePath}` : path;
return `
<label part="row">
<span part="label" title="${rowLabel}">${rowLabel}</span>
<input part="input" type="checkbox" value="${checked}"
name="${fullPath}" data-type="boolean" ${checked ? 'checked' : ''}>
</label>`;
},
});
/* === arrayDetail === */
export const arrayDetail = ({ value, config, path, instance, attributes = [], name = '', index }) => {
const cleanName = name?.replace(/\[\d+\]/g, '');
const rowLabel = config.render?.label
? resolveTemplateString(config.render.label, value, instance)
: 'label';
const rowValue = config.render?.value
? resolveTemplateString(config.render.value, value, instance)
: 'value';
const cols = rowValue.split('|').map(col => `<span>${col}</span>`).join('');
const arrayControl = config.render?.arrayControl || 'mark-remove';
return `
<details part="array-details" ${attrs(attributes)}${name ? ` name="${cleanName}"`:''}>
<summary part="row summary">
<output part="label" name="label_${name}">${rowLabel}</output>
<span part="value">
${icon('chevron right', 'sm', 'xs')}
<output name="value_${name}">${cols}</output>
${config.render?.delete ? `<label><input part="input delete" checked type="checkbox" name="${path}" data-array-control="${arrayControl}"></label>` : ''}
</span>
</summary>
${all(value, config.items, instance, false, path)}
</details>`;
};
/* === arrayDetails === */
export const arrayDetails = (params) => {
const { config } = params;
return renderArray({
...params,
renderItem: ({ value, config, path, instance, attributes, index }) =>
arrayDetail({
value,
config,
path,
instance,
attributes,
name: path,
index,
}),
entry: config.render?.add ? entry : null,
});
}
/* === arrayGrid === */
export const arrayGrid = (params) =>
renderArray({
...params,
renderItem: ({ value, config, path, instance }) =>
`<fieldset>${all(value, config.items, instance, false, path)}</fieldset>`,
});
/* === arrayLink === */
export const arrayLink = (params) => {
const { value, config, instance } = params;
const renderConfig = config?.render || {};
const title = config?.title || '';
// Handle predefined links from data.links
if (renderConfig.data?.links) {
const links = renderConfig.data.links.map(link => {
const href = resolveTemplateString(link.href, instance.data, instance);
const label = resolveTemplateString(link.label, instance.data, instance);
const target = link.target || '_self';
return `<a part="link action" href="${href}" target="${target}">${label}</a>`;
}).join('');
return `<nav part="nav actions">${links}</nav>`;
}
// Handle dynamic array data
if (Array.isArray(value)) {
const links = value.map(item => {
const href = resolveTemplateString(renderConfig.href || '', item, instance);
const label = resolveTemplateString(renderConfig.label || '', item, instance);
const target = renderConfig.target || '_self';
const value = resolveTemplateString(renderConfig.value || '', item, instance);
return `<a part="row summary" href="${href}" target="${target}">
<span part="label">${label}</span><span part="value">${value}</span>
</a>`;
}).join('');
return `<nav part="array-link"><strong>${title}</strong>${links}</nav>`;
}
return '';
};
/* === arrayUnit === */
export const arrayUnit = ({ value, config, path, instance, attributes = '', name = '', index }) => {
const rowValue = config.render?.value;
if (!rowValue) return '';
const rowLabel = config.render?.label
? resolveTemplateString(config.render.label, value, instance)
: 'label';
const cols = rowLabel.split('|').map(col => `<span>${col}</span>`).join('');
const arrayControl = config.render?.arrayControl || 'mark-remove';
// Ensure config.items and properties exist
const allContent = config.items?.properties
? Object.entries(config.items.properties)
.map(([key, itemConfig]) => {
const itemName = itemConfig.name || key;
const isHidden = itemName !== rowValue ? 'hidden' : `part="unit"`;
const content = safeRender(
instance.getRenderMethod(itemConfig.render?.method || 'input'),
{
label: itemConfig.title || key,
value: value[key] || '',
attributes: itemConfig.render?.attributes || [],
config: itemConfig,
instance,
path: `${path}.${key}`,
type: itemConfig.type || 'string',
}
);
return `<span ${isHidden}>${content}</span>`;
})
.join('')
: '';
const finalName = name.includes('[') ? name : `${name}[${index}]`;
return `
<fieldset part="array-unit fieldset" ${attrs(attributes)}${name ? ` name="${finalName}"` : ''}>
${allContent}
<output part="value" name="value_${finalName}">${cols}</output>
${config.render?.delete ? `<label><input part="input delete" checked type="checkbox" name="${path}" data-array-control="${arrayControl}"></label>` : ''}
</fieldset>`;
};
/* === arrayUnits === */
export const arrayUnits = (params) => {
const { config } = params;
return renderArray({
...params,
renderItem: ({ value, config, path, instance, attributes, index }) =>
arrayUnit({ value, config, path, instance, attributes, name: path, index }),
entry: config.render?.add ? entry : null,
});
};
/* === autosuggest === */
export const autosuggest = (params) => {
const config = params.config?.render?.autosuggest;
if (!config) return '';
const {
api,
apiArrayPath,
apiDisplayPath,
apiTextPath,
apiValuePath,
defaults,
label,
minlength,
required,
syncInstance
} = config;
const { path, formID, value: paramValue } = params;
const display = defaults && paramValue ? getObjectByPath(paramValue, defaults.display) || '' : '';
const value = defaults && paramValue ? getObjectByPath(paramValue, defaults.value) || '' : '';
const name = defaults?.value ? `${path}.${defaults.value}` : path;
const initialObject = defaults && paramValue ? {
[`${path}.${defaults.display}`]: display,
[`${path}.${defaults.value}`]: value
} : null;
return `
<auto-suggest
${api ? `api="${api}"` : ''}
${apiArrayPath ? `api-array-path="${apiArrayPath}"` : ''}
${apiDisplayPath ? `api-display-path="${apiDisplayPath}"` : ''}
${apiTextPath ? `api-text-path="${apiTextPath}"` : ''}
${apiValuePath ? `api-value-path="${apiValuePath}"` : ''}
${display ? `display="${display}"` : ''}
${label ? `label="${label}"` : ''}
list-mode="ul"
minlength="${minlength || 3}"
name="${name}"
noshadow
part="autosuggest"
path="${path}"
required="${required||false}"
${syncInstance ? `sync-instance="${syncInstance}"` : ''}
${value ? `value="${value}"` : ''}
${initialObject && !isEmpty(initialObject) ? `initial-object='${JSON.stringify(initialObject)}'` : ''}
${formID ? `form="${formID}"` : ''}></auto-suggest>`;
};
/* === barcode === */
export const barcode = ({ path }) => {
return `<barcode-scanner input path="${path}" styles></barcode-scanner>`;
};
/* === entry === */
export const entry = (params) => {
const { config, instance, path = '' } = params;
const formID = `form${uuid()}`;
const id = `popover-${uuid()}`;
const label = config.title || 'Add New Entry';
const renderAutoSuggest = !!config.render?.autosuggest;
const renderBarcodeScanner = !!config.render?.barcode;
const fields = Object.entries(config.items.properties)
.map(([propKey, propConfig]) => {
const attributes = [...(propConfig.render?.attributes || []), { form: formID }];
attributes.forEach(attr => {
if ('value' in attr) {
attr.value = resolveTemplateString(attr.value, instance.data, instance);
}
});
const renderMethod = propConfig.render?.method || 'input';
const options = renderMethod === 'select' ? fetchOptions(propConfig, instance) : [];
const renderFunction = renderMethod === 'select' ? select : input;
return renderFunction({
attributes,
label: propConfig.title,
options,
instance,
path: `${path}.${propKey}`,
type: propConfig.type || 'string'
});
}).join('');
if (!fields) return '';
instance.parent.insertAdjacentHTML('beforeend', `<form id="${formID}" data-popover="${id}" hidden></form>`);
return `
<nav part="nav">
${renderBarcodeScanner ? barcode({ path }) : ''}
<button type="button" part="micro" popovertarget="${id}" style="--_an:--${id};">
${icon('plus')}${label}
</button>
</nav>
<div id="${id}" popover="" style="--_pa:--${id};">
<fieldset part="fieldset" name="${path}-entry">
<legend part="legend">${label}</legend>
${renderAutoSuggest ? autosuggest({ config, path, formID }) : ''}
${fields}
<nav part="nav">
<button type="button" form="${formID}" part="button close" popovertarget="${id}" popovertargetaction="hide">${t('close', instance.lang, instance.i18n)}</button>
<button type="reset" form="${formID}" part="button reset">${t('reset', instance.lang, instance.i18n)}</button>
<button type="submit" form="${formID}" part="button add" data-render-method="${config.render?.addMethod || 'arrayDetail'}" data-custom="addArrayEntry" data-params='{ "path": "${path}" }'>${t('add', instance.lang, instance.i18n)}</button>
</nav>
</fieldset>
</div>`;
};
/* === fieldset === */
export const fieldset = ({ attributes, content, label, path }) => {
const fieldsetId = path ? `section_${path}` : '';
const fieldsetAttributes = attrs(attributes, '', [{ part: 'fieldset' }]);
const nameAttribute = path ? ` name="${path}-entry"` : '';
return `
<fieldset id="${fieldsetId}" ${fieldsetAttributes}${nameAttribute}>
<legend part="legend">${label}</legend>
${content}
</fieldset>`;
};
/* === icon === */
const icon = (type, size, stroke) => `<ui-icon type="${type||''}" size="${size||''}" stroke="${stroke||''}"></ui-icon>`;
/* === input === */
export const input = (params) => {
const { attributes = [], config, instance, label, path = '', type = 'string', value } = params;
const processedConfig = processRenderConfig(config, instance.data, instance);
const processedAttrs = processAttributes(attributes, instance.data, instance);
const hidden = processedAttrs.some(attr => attr.type === 'hidden');
const hiddenLabel = processedAttrs.some(attr => attr['hidden-label']);
const isRequired = processedAttrs.some(attr => attr.required === 'required');
let finalValue = value ?? processedAttrs.find(attr => attr.value !== undefined)?.value ?? '';
if (processedConfig?.render?.formatter) {
finalValue = resolveTemplateString(
processedConfig.render.formatter,
{ ...instance.data, value: finalValue },
instance
);
}
const filteredAttributes = processedAttrs.filter(attr => !('value' in attr));
const checked = filteredAttributes.some(attr => attr.type === 'checkbox') && finalValue ? ' checked' : '';
const inputElement = `<input part="input" value="${finalValue}" ${attrs(filteredAttributes, path)} data-type="${type}" ${checked}>`;
return hidden
? inputElement
: `<label part="row" ${hiddenLabel ? 'hidden' : ''}>
<span part="label">${isRequired ? `<abbr title="${instance ? t('required', instance.lang, instance.i18n): ''}">*</abbr>` : ''}${label}</span>
${inputElement}
</label>`;
};
/* === link === */
export const link = (params) => {
const { attributes = [], config, instance, label, path = '', value } = params;
const linkData = config?.render?.data || {};
const processedAttrs = processAttributes([
{ href: linkData.href, target: linkData.target || '_self' }
], instance.data, instance);
const rawLabel = value || linkData.label || config?.render?.value || '';
const linkValue = rawLabel.startsWith('${t:') ?
t(rawLabel.slice(4, -1), instance.lang, instance.i18n) :
resolveTemplateString(rawLabel, instance.data, instance, '');
return `
<label part="row">
<span part="label">${label}</span>
<a part="link" ${attrs(processedAttrs)}>${linkValue}</a>
</label>`;
};
/* === media === */
export const media = (params) => {
const { attributes = [], config, label, path = '', value } = params;
const { delete: itemDelete, summary: itemSrc = '', label: itemValue = '' } = config.render || {};
const mediaItem = (item, itemPath) => `
<label part="row">
${itemDelete ? `<input part="input delete" value="${item[itemValue]}" type="checkbox" checked data-custom="removeArrayEntry" data-params='{ "path": "${itemPath}" }'>` : ''}
<img part="img" src="${item[itemSrc]}" alt="">
</label>`;
const content = value.map((item, index) => mediaItem(item, path ? `${path}[${index}]` : '')).join('');
return fieldset({ label, content, attributes, path });
};
/* === renderArray === */
export const renderArray = ({ value, config, path, instance, renderItem, attributes = [], label = '', entry }) => {
const content = value.map((item, index) =>
renderItem({
value: item,
config,
path: path ? `${path}[${index}]` : '',
instance,
attributes,
index,
})
).join('');
const entryContent = entry ? entry({ config, instance, path }) : '';
return fieldset({
attributes,
content: `${content}${entryContent}`,
label,
path,
});
};
/* === richtext === */
export const richtext = (params) => {
const { attributes = [], instance, label, path = '', value } = params;
const isRequired = attributes.some(attr => attr.required === 'required');
return `
<label part="row">
<span part="label">${isRequired ? `<abbr title="${t('required', instance.lang, instance.i18n)}">*</abbr>` : ''}${label}</span>
<rich-text part="richtext" styles ${attrs(attributes, path)}>
${value || ''}
</rich-text>
</label>`;
};
/* === select === */
export const select = (params) => {
const {
attributes = [],
config,
label,
options = [],
path = '',
type = 'string',
value = -1,
instance,
} = params;
const processedAttrs = processAttributes(attributes, instance.data, instance);
const attributeValue = processedAttrs.find((attr) => 'value' in attr)?.value;
const selectedOption = config?.render?.selectedOption;
const selectedOptionDisabled = config?.render?.selectedOptionDisabled;
const action = config?.render?.action;
const renderLabel = config?.render?.label;
const valueField = config?.render?.value || 'value';
// Add selectedOption only if it's an object (with value/label properties)
let finalOptions = [...options];
if (
selectedOption &&
typeof selectedOption === 'object' &&
'value' in selectedOption
) {
finalOptions = [selectedOption, ...options];
}
// Process options
finalOptions = finalOptions.map((option) => {
// For template strings or direct property access in renderLabel
const optionLabel = renderLabel
? renderLabel.includes('${')
? resolveTemplateString(
renderLabel,
option,
instance
)
: option[renderLabel]
: option.label || option[valueField];
return {
value: option[valueField] !== undefined ? option[valueField] : (option.value !== undefined ? option.value : ''),
label: optionLabel || option.label || '',
};
});
// For primitive selectedOption, just use the value for matching
const finalValue =
typeof selectedOption === 'object' && 'value' in selectedOption
? selectedOption.value
: selectedOption !== undefined
? selectedOption
: value !== undefined // Changed from value !== -1 to value !== undefined
? value
: attributeValue;
const filteredAttributes = processedAttrs.filter(
(attr) => !('value' in attr)
);
// Create button HTML if action exists
const buttonHTML = action
? `<button type="button" part="button" ${buttonAttrs(
action
)}>${resolveTemplateString(
action.label,
instance.data,
instance
)}</button>`
: '';
const isRequired = processedAttrs.some(attr => attr.required === 'required');
return `
<label part="row${action ? ' action' : ''}">
<span part="label">${isRequired ? `<abbr title="${t('required', instance.lang, instance.i18n)}">*</abbr>` : ''}${label}</span>
<select part="select" ${attrs(
filteredAttributes,
path,
[],
['type']
)} data-type="${type}">
${finalOptions
.map((option) => {
const optionValue = String(option.value);
const isSelected = optionValue === String(finalValue);
return `
<option value="${optionValue}"
${isSelected ? 'selected' : ''}
${isSelected && selectedOptionDisabled ? 'disabled' : ''}>
${option.label}
</option>`;
})
.join('')}
</select>
${buttonHTML}
</label>`;
};
/* === textarea === */
export const textarea = (params) => {
const { attributes = [], label, path = '', value = '' } = params;
const textareaAttributes = attrs(attributes, path);
const finalValue = value === null ? '' : value;
return `
<label part="row">
<span part="label">${label}</span>
<textarea part="textarea" ${textareaAttributes}>${finalValue}</textarea>
</label>`;
};
/* === datamapper === */
export const datamapper = (params) => {
const config = params.config?.render?.datamapper || {};
return `
<data-mapper part="datamapper">
<div part="row">
<span part="label"><abbr title="required">*</abbr>${config.label}</span>
<div part="filewrap">
<input part="file" type="file" name="file" accept="${config.accept}" data-no-sync>
<small part="processed"></small>
<input type="checkbox" part="firstrow" name="firstrow" checked title="${config.firstRow}" data-no-sync>
</div>
</div>
</data-mapper>`;
}