@browser.style/data-entry
Version:
Dynamic data entry form component with JSON schema validation and internationalization support
963 lines (855 loc) • 32.1 kB
JavaScript
import { createDataEntryInstance } from './modules/factory.js';
import { convertValue, deepMerge, isEmpty, getObjectByPath, itemExists, setObjectByPath } from './modules/utility.js';
import { validateData as defaultValidateData } from './modules/validate.js';
import { mountComponents } from './modules/components.js';
/**
* DataEntry is a custom HTML element that provides a comprehensive data entry form with various functionalities, based on a provided JSON schema and data.
* It supports schema validation, internationalization, dynamic form rendering, and auto-save mechanisms.
* @author Mads Stoumann
* @version 1.0.31
* @summary 05-12-2024
*
* @class
* @extends HTMLElement
*
* @property {Object} data - The data object associated with the form.
* @property {Object} i18n - The internationalization object containing translations.
* @property {Object} lookup - The lookup object for reference data.
* @property {Object} schema - The JSON schema defining the structure of the form data.
* @property {Function} validateMethod - Custom validation method for the form data.
*
* @fires CustomEvent#de:custom - Dispatched when a custom button is clicked.
* @fires CustomEvent#de:entry - Dispatched when form data is processed.
* @listens CustomEvent#de:resetfields - Listens for resetting specific form fields or instance data.
*
* @example
* <data-entry lang="en" shadow debug></data-entry>
*
* @example
* document.querySelector('data-entry').data = { id: 1, name: 'Sample' };
*
* @example
* document.querySelector('data-entry').validateMethod = (schema, data) => {
* // Custom validation logic
* return { valid: true, errors: [] };
* };
*/
class DataEntry extends HTMLElement {
constructor() {
super();
// Initialize private properties
this._data = null;
this._schema = null;
this._lookup = [];
this._i18n = {};
this._constants = {};
this._customValidateData = null;
// Create form
this.form = document.createElement('form');
this.form.part = 'form';
// Create instance
this.lang = this.getAttribute('lang') || 'en';
this.instance = createDataEntryInstance(this);
this.instance.lang = this.lang;
}
// Data getter/setter
get data() {
return this._data;
}
set data(value) {
this._data = value;
if (this.instance) {
this.instance.data = value;
}
}
// Schema getter/setter
get schema() {
return this._schema;
}
set schema(value) {
this._schema = value;
if (this.instance) {
this.instance.schema = value;
// Update primaryKeys when schema changes
this.instance.primaryKeys = Array.isArray(value?.primaryKeys)
? value.primaryKeys
: (this.getAttribute('primary-keys')?.split(',') || ['id']);
}
}
// Lookup getter/setter
get lookup() {
return this._lookup;
}
set lookup(value) {
this._lookup = value || [];
if (this.instance) {
this.instance.lookup = this._lookup;
}
}
// i18n getter/setter
get i18n() {
return this._i18n;
}
set i18n(value) {
if (typeof value === 'object' && value !== null) {
this._i18n = value;
if (this.instance) {
this.instance.i18n = value;
}
} else {
console.error('i18n should be an object');
}
}
// Add these getter/setter methods alongside the other ones
get constants() {
return this._constants;
}
set constants(value) {
if (typeof value === 'object' && value !== null) {
this._constants = value;
if (this.instance) {
this.instance.constants = value;
}
} else {
console.error('Constants should be an object');
}
}
// Validation method getter/setter
get validateMethod() {
return this._customValidateData;
}
set validateMethod(method) {
if (typeof method === 'function') {
this._customValidateData = method;
} else {
console.error('Validation method must be a function');
}
}
/**
* Handles the component's connection to the DOM.
*
* This method is called when the element is added to the document's DOM. It sets up the shadow DOM if required,
* attaches the form to the shadow DOM, and adds event listeners for form input and submission. It also loads
* necessary resources, merges translations and messages, validates the JSON schema, and renders the component.
*
* @async
* @method connectedCallback
* @returns {Promise<void>} A promise that resolves when the component is fully connected and rendered.
*/
async connectedCallback() {
const shadowRoot = this.hasAttribute('shadow') ? this.attachShadow({ mode: 'open' }) : this;
shadowRoot.appendChild(this.form);
this.addEventListener('de:notify', (event) => {
const { code, message, type } = event.detail;
this.notify(code || 0, message, type);
});
this.addEventListener('de:resetfields', ({ detail: { fields, resetValue } }) => {
this.resetFields(fields, resetValue);
});
this.addEventListener('de:submit', (event) => {
const { action, enctype, method } = event.detail;
this.handleDataSubmission(action, method, enctype);
});
/* form events */
this.form.addEventListener('input', this.syncInstanceData.bind(this));
this.form.addEventListener('reset', (event) => {
const richTextElements = this.querySelectorAll('rich-text');
richTextElements.forEach(richTextElement => richTextElement.dispatchEvent(new Event('rt:reset')));
});
this.form.addEventListener('submit', (event) => {
event.preventDefault();
const submitter = event.submitter;
if (submitter?.dataset.handler) {
this.dispatchEvent(new CustomEvent('de:custom', {
detail: {
instance: this.instance,
submitter
}
}));
} else {
this.handleDataSubmission();
}
});
await this.loadResources();
if (this.instance.schema?.translations) {
this.i18n = deepMerge(this.i18n || {}, this.instance.schema.translations);
}
this.instance.i18n = this.i18n || {};
if (this.instance.schema?.messages) {
this.messages = deepMerge(this.messages || [], this.instance.schema.messages, 'code');
}
if (isEmpty(this.instance.data) || isEmpty(this.instance.schema)) {
this.debugLog('Data or schema is empty. Skipping render.');
return;
}
if (this.validateJSON()) {
const validationResult = await this.validateData();
if (!validationResult.valid) {
this.debugLog('Schema validation failed. Skipping render.');
return;
}
}
this.renderAll();
}
/**
* Called when the element is disconnected from the document's DOM.
* Clears the auto-save timer if it exists and performs cleanup before rendering.
*/
disconnectedCallback() {
if (this.autoSaveTimer) {
clearInterval(this.autoSaveTimer);
}
this.cleanupBeforeRender();
}
/**
* Adds a new entry to an array within the form data.
*
* @param {HTMLElement} element - The form element that triggered the addition.
* @param {string} path - The path to the array within the form data.
* @param {string} [insertBeforeSelector='[part="nav"]'] - The CSS selector to determine where to insert the new entry in the DOM.
*
* @returns {void}
*
* @throws {Error} If the path does not reference an array in the data.
* @throws {Error} If the element with the specified selector is not found within the fieldset.
*/
addArrayEntry(element, path, insertBeforeSelector = `[part="nav"]`) {
const form = element.form;
if (element.type === 'submit') {
if (!form.checkValidity()) {
return;
}
event.preventDefault();
}
const formElements = Array.from(form.elements).filter(el => el.name.startsWith(`${path}.`));
const array = getObjectByPath(this.instance.data, path);
if (!Array.isArray(array)) {
this.notify(1002, `Path "${path}" does not reference an array in the data.`);
return;
}
const fieldset = this.form.querySelector(`fieldset[name="${path}-entry"]`);
const schema = getObjectByPath(this.instance.schema, `properties.${path}`);
if (!fieldset || !schema) {
this.debugLog(`Fieldset with path "${path}" or schema not found.`);
return;
}
const newObject = formElements.reduce((acc, el) => {
const fieldPath = el.name.slice(path.length + 1);
const dataType = el.dataset.type || 'string';
acc[fieldPath] = convertValue(el.value, dataType, el.type, el.checked);
return acc;
}, {});
// Check for duplicates
const uniqueProps = this.getUniqueProperties(schema);
if (uniqueProps.length && itemExists(array, newObject, uniqueProps)) {
this.notify(0, 'Item already exists', 'warning');
return;
}
array.push(newObject);
const renderMethod = element.dataset.renderMethod || 'arrayDetail';
if (!this.instance.methods[renderMethod]) {
this.notify(1003, `Render method "${renderMethod}" not found.`);
return;
}
const newDetail = this.instance.methods[renderMethod]({
value: newObject,
config: schema,
path: `${path}[${array.length - 1}]`,
instance: this.instance,
attributes: [],
name: path,
index: array.length - 1
});
const siblingElm = fieldset.querySelector(insertBeforeSelector);
if (siblingElm) {
siblingElm.insertAdjacentHTML('beforebegin', newDetail);
} else {
this.notify(1004, `Element with selector "${insertBeforeSelector}" not found within the fieldset.`);
return;
}
form.reset();
const popover = this.form.querySelector(`#${form.dataset.popover}`);
if (popover) popover.hidePopover();
this.processData();
}
/**
* Adds multiple entries to an array in the form data
* @param {string} path - The path to the array in the form data
* @param {Array} entries - Array of objects to add
* @param {string} renderMethod - The render method to use (e.g., 'arrayDetail')
*/
addArrayEntries(path, entries, renderMethod = 'arrayDetail') {
if (!Array.isArray(entries) || entries.length === 0) return;
// Find the target fieldset
const fieldset = this.form.querySelector(`fieldset[name="${path}-entry"]`);
if (!fieldset) {
this.notify(1002, `Path "${path}" not found in form.`);
return;
}
// Get the array from instance data
const array = getObjectByPath(this.instance.data, path);
if (!Array.isArray(array)) {
this.notify(1002, `Path "${path}" does not reference an array in the data.`);
return;
}
// Get the schema
const schema = getObjectByPath(this.instance.schema, `properties.${path}`);
if (!schema) {
this.notify(1002, `Schema for path "${path}" not found.`);
return;
}
// Get unique properties from schema
const uniqueProps = this.getUniqueProperties(schema);
// Filter out duplicates
const newEntries = entries.filter(entry => {
const isDuplicate = uniqueProps.length && itemExists(array, entry, uniqueProps);
if (isDuplicate) {
this.notify(0, `Skipping duplicate item: ${JSON.stringify(entry)}`, 'info');
}
return !isDuplicate;
});
if (newEntries.length === 0) {
this.notify(0, 'No new items to add', 'warning');
return;
}
// Process remaining entries
let insertBeforeElement = fieldset?.querySelector('[part="nav"]');
// Get starting index from current array length
let currentIndex = array.length;
newEntries.forEach(entry => {
const newDetail = this.instance.methods[renderMethod]({
value: entry,
config: schema,
path: `${path}[${currentIndex}]`,
instance: this.instance,
attributes: [],
name: `${path}[${currentIndex}]`,
index: currentIndex
});
if (insertBeforeElement) {
insertBeforeElement.insertAdjacentHTML('beforebegin', newDetail);
} else {
fieldset?.insertAdjacentHTML('beforeend', newDetail);
}
// Push to array AFTER rendering to maintain correct indexing
array.push(entry);
currentIndex++;
});
if (newEntries.length < entries.length) {
this.notify(0, `Added ${newEntries.length} of ${entries.length} items`, 'info');
}
this.processData();
}
/**
* Binds custom buttons within the form to dispatch a custom event when clicked.
*
* This method selects all buttons within the form that have a `data-method="custom"` attribute
* and attaches a click event listener to each of them. When a button is clicked, it prevents
* the default action and dispatches a `de:custom` event with the instance data and the button
* that was clicked as the event detail.
*
* @fires CustomEvent#de:custom
*/
bindCustomButtons() {
this.form.querySelectorAll('button[data-handler]').forEach(button => {
button.addEventListener('click', (event) => {
event.preventDefault();
this.dispatchEvent(new CustomEvent('de:custom', {
detail: {
instance: this.instance,
submitter: event.currentTarget
}
}));
});
});
}
/**
* Binds custom events to elements within a given container.
*
* This function searches for elements with the `data-custom` attribute within the specified
* container and binds click event listeners to them. When an element is clicked, it invokes
* the corresponding custom function defined in the `componentInstance`.
*
* @param {HTMLElement} elementContainer - The container element that holds the elements to bind events to.
* @param {Object} componentInstance - The instance of the component that contains custom functions.
*/
bindCustomEvents(elementContainer, componentInstance) {
elementContainer.querySelectorAll('[data-custom]').forEach(element => {
const customFunc = element.dataset.custom;
const params = element.dataset.params ? JSON.parse(element.dataset.params) : {};
const handler = componentInstance.custom?.[customFunc] || componentInstance[customFunc];
if (typeof handler === 'function') {
element.addEventListener('click', () => handler.call(componentInstance, element, ...Object.values(params)));
}
});
}
/**
* Cleans up the form before rendering by performing the following actions:
* - Unbinds custom buttons by replacing them with their clones.
* - Unbinds custom events by replacing elements with the `data-custom` attribute with their clones.
* - Clears the innerHTML of the form to remove all elements.
*/
cleanupBeforeRender() {
this.form.querySelectorAll('button[data-handler]').forEach(button => {
button.removeEventListener('click', this.handleCustomClick);
});
this.form.querySelectorAll('[data-custom]').forEach(element => {
const customFunc = element.dataset.custom;
if (customFunc && this[customFunc]) {
element.removeEventListener('click', this[customFunc]);
}
});
this.form.innerHTML = '';
}
/**
* Logs debug messages to the console if the 'debug' attribute is present.
*
* @param {...any} args - The messages or objects to log.
*/
debugLog(...args) {
if (this.hasAttribute('debug')) {
console.log(...args);
} else {
console.warn('An issue occurred. Please try again.');
}
}
/**
* Fetches a resource from a URL specified by an attribute.
*
* @param {string} attribute - The name of the attribute containing the URL to fetch.
* @returns {Promise<Object|null>} A promise that resolves to the fetched resource as a JSON object, or null if an error occurs or the URL is not provided.
* @throws {Error} Throws an error if the HTTP response is not ok.
*/
async fetchResource(attribute) {
const url = this.getAttribute(attribute);
if (!url) return null;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
this.notify(1001, `Error fetching ${attribute}: ${error.message}`);
return null;
}
}
/**
* Recursively filters out entries from the provided data structure based on specified keys.
*
* @param {Object|Array} data - The data structure to filter, which can be an object or an array.
* @param {Array<string>} [keys=['_remove']] - The keys to filter out from the data structure. Defaults to ['_remove'].
* @returns {Object|Array} - The filtered data structure with specified keys removed.
*/
filterRemovedEntries(data, keys = ['_remove']) {
const filterRecursive = (obj) => {
if (Array.isArray(obj)) {
return obj.filter(item => !keys.some(key => item[key])).map(filterRecursive);
} else if (obj && typeof obj === 'object') {
return Object.entries(obj).reduce((acc, [key, value]) => {
if (!keys.includes(key)) {
acc[key] = filterRecursive(value);
}
return acc;
}, {});
}
return obj;
};
return filterRecursive(data);
}
/**
* Retrieves the error message and type based on the provided error code.
*
* @param {string} code - The error code to search for in the messages array.
* @returns {{ message: string, type: string }} An object containing the error message and its type.
*/
getErrorMessage(code) {
const entry = (this.messages || []).find(msg => msg.code === code) || {};
return { message: entry.message || '', type: entry.type || 'error' };
}
/**
* Handles the submission of form data.
*
* @param {string} action - The action URL to which the form data will be submitted.
* @param {string} method - The HTTP method to be used for the form submission (e.g., 'POST', 'GET').
* @param {string} [enctype='form'] - The encoding type of the form data (e.g., 'application/json', 'multipart/form-data').
*
* @returns {void}
*/
handleDataSubmission(action, method, enctype) {
const formAction = action || this.form.getAttribute('action');
const formMethod = method || this.form.getAttribute('method') || 'POST';
const formEnctype = enctype || this.form.getAttribute('enctype');
const filteredData = this.filterRemovedEntries(this.instance.data);
const isMultipart = formEnctype.includes('multipart/form-data');
const headers = isMultipart ? {} : { 'Content-Type': formEnctype };
let data;
if (formEnctype.includes('json')) {
data = JSON.stringify(filteredData);
} else if (isMultipart) {
const hasFile = Array.from(this.form.elements).some(el => el.type === 'file');
if (!hasFile) {
this.debugLog('Warning: Multipart form but no files detected.');
}
data = this.prepareFormData();
} else {
data = new URLSearchParams(filteredData).toString();
}
const id = this.instance.primaryKeys.length > 0 ? filteredData[this.instance.primaryKeys[0]] : null;
const actionUrl = id ? formAction.replace(':id', id) : formAction.replace('/:id', '');
if (formAction) {
fetch(actionUrl, {
method: formMethod,
headers,
body: data
})
.then(response => {
if (!response.ok) {
this.notify(1007, `HTTP error! status: ${response.statusText}`);
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(result => {
let record = Array.isArray(result) ? result[0] : result;
const recordHasPrimaryKeys = this.instance.primaryKeys.every(key => record && record[key]);
if (formMethod === 'DELETE') {
this.dispatchEvent(new CustomEvent('de:record-deleted', {
detail: record
}));
this.notify(1005, 'Record deleted successfully!');
} else if (recordHasPrimaryKeys && !id) {
this.dispatchEvent(new CustomEvent('de:record-created', {
detail: record
}));
this.notify(1005, 'New record created successfully!');
} else if (recordHasPrimaryKeys) {
this.dispatchEvent(new CustomEvent('de:record-upserted', {
detail: record
}));
this.notify(1005, 'Record upserted successfully!');
} else if (result.success) {
this.notify(1005, result.message || 'Operation completed successfully!');
} else {
this.notify(1006, 'An error occurred.');
}
})
.catch(error => {
let statusCode = 1006;
let errorMessage = 'Network issue detected';
const statusMatch = error.message.match(/status:\s*(\d+)/);
if (statusMatch) {
statusCode = parseInt(statusMatch[1], 10);
errorMessage = `HTTP error! status: ${statusCode}`;
}
this.notify(statusCode, errorMessage);
});
} else {
this.processData();
}
}
/**
* Handles navigation by adding click event listeners to links matching the specified selector.
* When a link is clicked, it prevents the default action, scrolls smoothly to the target element,
* and toggles the active class on the clicked link. Accounts for sticky parent element height.
*
* @param {string} [selector='a[part="link"]'] - The CSS selector for the links to handle navigation.
* @param {string} [activeClass='active'] - The class to add to the clicked link to indicate it is active.
*/
handleNavigation(selector = 'a[part="link"]', visibleClass = 'visible', activeClass = 'active') {
const links = Array.from(this.form.querySelectorAll(selector));
if (!links.length) return;
const parentHeight = links[0].parentElement?.offsetHeight || 0;
let lastActiveLink = null;
const io = new IntersectionObserver(entries => {
// First, handle visibility for changed entries
entries.forEach(entry => {
const id = entry.target.id;
const link = links.find(link => link.getAttribute('href') === `#${id}`);
if (link) {
link.classList.toggle(visibleClass, entry.isIntersecting);
}
});
// Then, find all visible links and set active state on last one
const visibleLinks = links.filter(link => link.classList.contains(visibleClass));
if (visibleLinks.length > 0) {
if (lastActiveLink) {
lastActiveLink.classList.remove(activeClass);
}
lastActiveLink = visibleLinks[visibleLinks.length - 1];
lastActiveLink.classList.add(activeClass);
}
}, {
rootMargin: `-${parentHeight}px 0px 0px 0px`,
threshold: [0]
});
// Set up observers and click handlers
links.forEach(link => {
const targetId = link.getAttribute('href')?.substring(1);
const target = targetId && document.getElementById(targetId);
if (target) {
io.observe(target);
link.addEventListener('click', event => {
event.preventDefault();
target.scrollIntoView({ behavior: 'smooth' });
});
}
});
}
/**
* Asynchronously loads various resources required for data entry.
*
* This method fetches the following resources in parallel:
* - data
* - schema
* - lookup
* - i18n (internationalization)
* - messages
*
* Once fetched, the resources are assigned to the corresponding properties of the instance.
* If the lookup resource is not available, it defaults to an empty array.
* If the i18n resource is not available, it defaults to an empty object.
* If the messages resource is not available, it defaults to null.
*
* @returns {Promise<void>} A promise that resolves when all resources are loaded.
*/
async loadResources() {
const [data, schema, lookup, i18n, messages] = await Promise.all([
this.fetchResource('data'),
this.fetchResource('schema'),
this.fetchResource('lookup'),
this.fetchResource('i18n'),
this.fetchResource('messages')
]);
this.data = data;
this.schema = schema;
this.lookup = lookup;
this.i18n = i18n || {};
this.messages = messages;
}
/**
* Displays a notification message based on error code or custom message
* @param {number} code - The error code. If greater than 0 and this.messages exists, retrieves message from error codes
* @param {string} [customMessage=''] - Optional custom message to display if no error code message exists
* @param {string} [notificationType='info'] - Optional notification type ('info', 'error', etc.)
* @returns {void}
*/
notify(code, customMessage = '', notificationType = 'info') {
// If we have a code and this.messages exists, try to get the message from there
const messageFromCode = code > 0 && this.messages ?
this.getErrorMessage(code) :
{ message: customMessage, type: notificationType };
if (messageFromCode.message) {
if (typeof this.showMsg === 'function') {
this.showMsg(messageFromCode.message, messageFromCode.type, 3000);
} else {
this.debugLog(`[${messageFromCode.type.toUpperCase()}] ${messageFromCode.message}`);
}
} else {
this.debugLog(`Error ${code}: ${customMessage}`);
}
}
/**
* Prepares form data by iterating over the form elements and appending their values to a FormData object.
*
* @returns {FormData} The FormData object containing the form elements' names and values.
*/
prepareFormData() {
const formData = new FormData();
Array.from(this.form.elements).forEach(element => {
if (element.name && !element.disabled && element.value !== undefined && element.value !== 'undefined') {
let value = element.type === 'checkbox'
? (element.checked ? (element.value || 'true') : (element.dataset.unchecked || 'false'))
: element.value || '';
if (element.hasAttribute('data-encoded')) {
try {
value = decodeURIComponent(value);console.log(value)
} catch (error) {
this.debugLog(`Failed to decode value for ${element.name}: ${value}`, error);
}
}
formData.append(element.name, value);
}
});
return formData;
}
/**
* Processes the form data based on the form's enctype attribute.
* If the enctype includes 'json', it uses the instance's data.
* Otherwise, it prepares the form data.
* Logs the processed data for debugging and dispatches a 'dataEntry' event with the data.
*
* @method processData
* @fires CustomEvent#dataEntry - Dispatched with the processed data.
*/
processData(node) {
const enctype = this.form.getAttribute('enctype') || 'multipart/form-data';
const data = enctype.includes('json') ? this.instance.data : this.prepareFormData();
this.debugLog('Processing data:', this.instance.data);
this.dispatchEvent(new CustomEvent('de:entry', { detail: { data, node: node || null } }));
}
/* === renderAll: Renders all form elements based on the data and schema */
/**
* Asynchronously renders all components based on the instance's data and schema.
*
* This method performs the following steps:
* 1. Checks if the instance's data or schema is empty and skips rendering if true.
* 2. Calls the `all` method on the instance's methods with the instance's data, schema, and other parameters.
* 3. Mounts components using the inner HTML of the form.
* 4. Binds custom buttons and events to the form.
* 5. Handles navigation.
* 6. Sets up auto-save functionality if the form's dataset specifies an auto-save interval.
*
* @async
* @returns {Promise<void>} A promise that resolves when all components are rendered and additional setup is complete.
*/
async renderAll() {
if (isEmpty(this.instance.data) || isEmpty(this.instance.schema)) {
this.notify(1001, 'Data or schema is empty. Cannot proceed.');
return;
}
this.cleanupBeforeRender();
this.instance.methods.all(this.instance.data, this.instance.schema, this.instance, true, '', this.form);
await mountComponents(this.form.innerHTML, this);
this.bindCustomButtons();
this.bindCustomEvents(this.form, this);
this.handleNavigation();
const autoSaveInterval = parseInt(this.form.dataset.autoSave, 10);
if (!isNaN(autoSaveInterval) && autoSaveInterval > 0) {
this.setupAutoSave(autoSaveInterval);
}
}
/**
* Resets the specified fields in the form and the instance data to a given value.
*
* @param {string[]} fields - An array of field names to be reset.
* @param {string} [resetValue=''] - The value to reset the fields to. Defaults to an empty string.
*/
resetFields(fields, resetValue = '') {
fields.forEach(field => {
const formElement = this.form.elements[field];
if (formElement) {
if (formElement.hasAttribute('data-encoded')) {
const richTextElement = formElement.closest('rich-text');
if (richTextElement) {
richTextElement.dispatchEvent(new Event('rt:clear'));
}
} else {
formElement.value = resetValue;
}
}
setObjectByPath(this.instance.data, field, resetValue);
});
}
/**
* Sets up an auto-save mechanism that triggers data submission at specified intervals.
*
* @param {number} intervalInSeconds - The interval in seconds at which data should be auto-saved.
*/
setupAutoSave(intervalInSeconds) {
if (this.autoSaveTimer) {
clearInterval(this.autoSaveTimer);
}
this.autoSaveTimer = setInterval(() => {
this.handleDataSubmission();
this.debugLog(`Auto-saving data every ${intervalInSeconds} seconds.`);
}, intervalInSeconds * 1000);
}
/**
* Synchronizes instance data based on the event from a form input.
*
* @param {Event} event - The event object from the form input.
* @param {HTMLFormElement} event.target.form - The form element that triggered the event.
* @param {string} event.target.name - The name attribute of the form input.
* @param {string} event.target.value - The value of the form input.
* @param {string} event.target.type - The type of the form input (e.g., "checkbox", "text").
* @param {boolean} event.target.checked - The checked state of the form input (for checkboxes).
* @param {DOMStringMap} event.target.dataset - The dataset of the form input, containing custom data attributes.
*/
syncInstanceData(event) {
const { form, name, type, checked, dataset } = event.target;
if (!name || form !== this.form || event.target.hasAttribute('data-no-sync')) return;
let value = event.detail?.content || event.target.value;
const isEncoded = event.detail?.isEncoded || false;
if (isEncoded) {
try {
value = decodeURIComponent(value);
} catch (error) {
console.warn(`Failed to decode value: ${value}`, error);
}
}
const currentData = getObjectByPath(this.instance.data, name);
if (type === 'checkbox' && dataset.arrayControl) {
if (checked) {
if (currentData && currentData._remove) delete currentData._remove;
this.debugLog(`Undoing delete: Removed _remove flag at path "${name}".`);
} else {
if (currentData) currentData._remove = true;
this.notify(1003, `Marked object at path "${name}" for removal.`);
}
if (dataset.arrayControl !== "mark-remove") {
this.dispatchEvent(new CustomEvent('de:array-control', {
detail: {
action: dataset.arrayControl,
checked,
data: this.instance.data,
name,
node: event.target
}
}));
}
} else {
const dataType = dataset.type;
setObjectByPath(this.instance.data, name, convertValue(value, dataType, type, checked));
}
this.processData(event.target);
}
/**
* Validates the data against the schema.
* Uses a custom validation function if provided, otherwise defaults to `defaultValidateData`.
*
* @async
* @returns {Promise<Object>} The result of the validation, containing a `valid` boolean and an `errors` array if invalid.
*/
async validateData() {
const validateData = this._customValidateData || defaultValidateData;
const validationResult = validateData(this.instance.schema, this.instance.data);
if (!validationResult.valid) {
validationResult.errors.forEach(error => {
this.debugLog(`Validation error in ${error.dataPath}: ${error.message}`);
this.notify(1008, `Validation failed: ${error.message}`);
});
}
return validationResult;
}
/**
* Checks if JSON schema validation is enabled.
*
* @returns {boolean} True if validation is enabled, false otherwise.
*/
validateJSON() {
return !this.hasAttribute('novalidate');
}
/**
* Gets unique identifier properties from schema
* @param {Object} schema - Schema section for array
* @returns {Array} Array of property names to use as unique identifiers
*/
getUniqueProperties(schema) {
const properties = schema?.items?.properties || {};
// First check for explicit unique identifiers
const uniqueProps = Object.entries(properties)
.filter(([_, config]) => config.unique === true)
.map(([key]) => key);
// If no explicit unique properties, use id or *_id fields
if (!uniqueProps.length) {
return Object.keys(properties).filter(key =>
key === 'id' || key.endsWith('_id')
);
}
return uniqueProps;
}
}
/* === Register element */
customElements.define('data-entry', DataEntry);
export default { DataEntry };