UNPKG

invokers

Version:

A powerful, platform-first JavaScript library for creating modern user interfaces with declarative HTML. Features universal command chaining, conditional execution, and declarative workflow orchestration via `data-and-then` attributes and `<and-then>` ele

592 lines (517 loc) 21.8 kB
/** * @file data.ts * @summary Data Management Command Pack for the Invokers library. * @description * This module provides commands for sophisticated data manipulation including * array operations, context management, and reactive data binding. These commands * are especially powerful when combined with the advanced templating system. * * @example * ```javascript * import { registerDataCommands } from 'invokers/commands/data'; * import { InvokerManager } from 'invokers'; * * const invokerManager = InvokerManager.getInstance(); * registerDataCommands(invokerManager); * ``` */ import type { InvokerManager } from '../core'; import type { CommandCallback, CommandContext } from '../index'; import { createInvokerError, ErrorSeverity, isInterpolationEnabled } from '../index'; import { interpolateString } from '../advanced/interpolation'; import { resolveTargets } from '../target-resolver'; /** * Data manipulation commands for complex data operations and state management. * Includes array manipulation, data binding, and reactive data operations. */ const dataCommands: Record<string, CommandCallback> = { // --- Basic Data Commands --- /** * `--data:set`: Sets a data attribute on the target element. * @example `<button command="--data:set:userId:123" commandfor="#profile">Set User ID</button>` */ "--data:set": ({ invoker, targetElement, params }: CommandContext) => { const key = params[0]; let value = params[1]; if (!key) { throw createInvokerError('Data set command requires a key parameter', ErrorSeverity.ERROR, { command: '--data:set', element: invoker }); } // Interpolate value if interpolation is enabled and contains {{...}} if (isInterpolationEnabled() && value && value.includes('{{')) { const safeInvoker = { dataset: { ...invoker.dataset }, value: (invoker as any).value || '', }; const context = { this: safeInvoker, data: { ...document.body.dataset, ...(invoker.parentElement?.dataset || {}) }, event: (invoker as any).triggeringEvent, }; value = interpolateString(value, context); } targetElement.dataset[key] = value || ''; // Dispatch custom event for reactive updates const eventKey = key.split(':')[0]; targetElement.dispatchEvent(new CustomEvent(`data:${eventKey}`, { bubbles: true, detail: { value: `${key}:${value || ''}` } })); // Handle data binding if specified let bindTo = invoker.dataset.bindTo; let bindAs = invoker.dataset.bindAs || `data:${key}`; if (bindTo) { // Interpolate bindTo and bindAs if they contain expressions const safeInvoker = { dataset: { ...invoker.dataset }, value: (invoker as any).value || '', }; if (isInterpolationEnabled() && bindTo.includes('{{')) { bindTo = interpolateString(bindTo, { this: safeInvoker, data: { ...document.body.dataset, ...(invoker.parentElement?.dataset || {}) }, event: (invoker as any).triggeringEvent, }); } if (isInterpolationEnabled() && bindAs.includes('{{')) { bindAs = interpolateString(bindAs, { this: safeInvoker, data: { ...document.body.dataset, ...(invoker.parentElement?.dataset || {}) }, event: (invoker as any).triggeringEvent, }); } const bindTargets = resolveTargets(bindTo, invoker) as HTMLElement[]; bindTargets.forEach(target => { if (bindAs.startsWith('data:')) { const dataKey = bindAs.substring(5).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); target.dataset[dataKey] = value || ''; } else if (bindAs === 'text') { target.textContent = value || ''; } else if (bindAs === 'value' && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)) { (target as HTMLInputElement).value = value || ''; } else if (bindAs.startsWith('attr:')) { const attrName = bindAs.substring(5); target.setAttribute(attrName, value || ''); } // Dispatch event on bound target as well target.dispatchEvent(new CustomEvent(`data:${key}`, { bubbles: true, detail: { value: `${key}:${value}` } })); }); } }, /** * `--data:copy`: Copies a data attribute from a source element to the target. * @example `<button command="--data:copy:userId" commandfor="#edit-form" data-copy-from="#user-profile">Edit User</button>` */ "--data:copy": ({ invoker, targetElement, params }: CommandContext) => { const key = params[0]; if (!key) { throw createInvokerError('Data copy command requires a key parameter', ErrorSeverity.ERROR, { command: '--data:copy', element: invoker }); } const sourceSelector = invoker.dataset.copyFrom; let sourceElement: HTMLElement | null = invoker; if (sourceSelector) { sourceElement = document.querySelector(sourceSelector); if (!sourceElement) { throw createInvokerError(`Source element with selector "${sourceSelector}" not found`, ErrorSeverity.ERROR, { command: '--data:copy', element: invoker }); } } const value = sourceElement.dataset[key]; if (value !== undefined) { targetElement.dataset[key] = value; } }, // --- Array Manipulation Commands --- /** * `--data:set:array:push`: Adds an item to the end of an array stored in a data attribute. * @example `<button command="--data:set:array:push:todos" data-value='{"title": "New Task"}' commandfor="#app">Add Todo</button>` */ "--data:set:array:push": ({ invoker, targetElement, params }: CommandContext) => { const arrayKey = params[0]; if (!arrayKey) { throw createInvokerError('Array push command requires an array key parameter', ErrorSeverity.ERROR, { command: '--data:set:array:push', element: invoker }); } const valueToAdd = invoker.dataset.value; if (!valueToAdd) { throw createInvokerError('Array push command requires a data-value attribute', ErrorSeverity.ERROR, { command: '--data:set:array:push', element: invoker }); } let arrayData: any[] = []; try { const existingData = targetElement.dataset[arrayKey]; arrayData = existingData ? JSON.parse(existingData) : []; } catch (e) { arrayData = []; } try { const newItem = JSON.parse(valueToAdd); arrayData.push(newItem); targetElement.dataset[arrayKey] = JSON.stringify(arrayData); } catch (e) { throw createInvokerError('Invalid JSON in data-value attribute', ErrorSeverity.ERROR, { command: '--data:set:array:push', element: invoker }); } }, /** * `--data:set:array:remove`: Removes an item at a specific index from an array stored in a data attribute. * @example `<button command="--data:set:array:remove:todos" data-index="2" commandfor="#app">Remove Item</button>` */ "--data:set:array:remove": ({ invoker, targetElement, params }: CommandContext) => { const arrayKey = params[0]; if (!arrayKey) { throw createInvokerError('Array remove command requires an array key parameter', ErrorSeverity.ERROR, { command: '--data:set:array:remove', element: invoker }); } const indexToRemove = parseInt(invoker.dataset.index || '0', 10); if (isNaN(indexToRemove)) { throw createInvokerError('Array remove command requires a valid data-index attribute', ErrorSeverity.ERROR, { command: '--data:set:array:remove', element: invoker }); } let arrayData: any[] = []; try { const existingData = targetElement.dataset[arrayKey]; arrayData = existingData ? JSON.parse(existingData) : []; } catch (e) { arrayData = []; } if (indexToRemove >= 0 && indexToRemove < arrayData.length) { arrayData.splice(indexToRemove, 1); targetElement.dataset[arrayKey] = JSON.stringify(arrayData); } }, /** * `--data:set:array:update`: Updates an item at a specific index in an array stored in a data attribute. * @example `<button command="--data:set:array:update:todos" data-index="1" data-value='{"title": "Updated"}' commandfor="#app">Update Item</button>` */ "--data:set:array:update": ({ invoker, targetElement, params }: CommandContext) => { const arrayKey = params[0]; if (!arrayKey) { throw createInvokerError('Array update command requires an array key parameter', ErrorSeverity.ERROR, { command: '--data:set:array:update', element: invoker }); } const indexToUpdate = parseInt(invoker.dataset.index || '0', 10); const valueToUpdate = invoker.dataset.value; if (isNaN(indexToUpdate)) { throw createInvokerError('Array update command requires a valid data-index attribute', ErrorSeverity.ERROR, { command: '--data:set:array:update', element: invoker }); } if (!valueToUpdate) { throw createInvokerError('Array update command requires a data-value attribute', ErrorSeverity.ERROR, { command: '--data:set:array:update', element: invoker }); } let arrayData: any[] = []; try { const existingData = targetElement.dataset[arrayKey]; arrayData = existingData ? JSON.parse(existingData) : []; } catch (e) { arrayData = []; } if (indexToUpdate >= 0 && indexToUpdate < arrayData.length) { try { const updateData = JSON.parse(valueToUpdate); arrayData[indexToUpdate] = { ...arrayData[indexToUpdate], ...updateData }; targetElement.dataset[arrayKey] = JSON.stringify(arrayData); } catch (e) { throw createInvokerError('Invalid JSON in data-value attribute', ErrorSeverity.ERROR, { command: '--data:set:array:update', element: invoker }); } } }, /** * `--data:set:array:insert`: Inserts an item at a specific index in an array stored in a data attribute. * @example `<button command="--data:set:array:insert:todos" data-index="1" data-value='{"title": "Inserted Item"}' commandfor="#app">Insert at Position 1</button>` */ "--data:set:array:insert": ({ invoker, targetElement, params }: CommandContext) => { const arrayKey = params[0]; if (!arrayKey) { throw createInvokerError('Array insert command requires an array key parameter', ErrorSeverity.ERROR, { command: '--data:set:array:insert', element: invoker }); } const indexToInsert = parseInt(invoker.dataset.index || '0', 10); const valueToInsert = invoker.dataset.value; if (isNaN(indexToInsert)) { throw createInvokerError('Array insert command requires a valid data-index attribute', ErrorSeverity.ERROR, { command: '--data:set:array:insert', element: invoker }); } if (!valueToInsert) { throw createInvokerError('Array insert command requires a data-value attribute', ErrorSeverity.ERROR, { command: '--data:set:array:insert', element: invoker }); } let arrayData: any[] = []; try { const existingData = targetElement.dataset[arrayKey]; arrayData = existingData ? JSON.parse(existingData) : []; } catch (e) { arrayData = []; } try { const newItem = JSON.parse(valueToInsert); arrayData.splice(indexToInsert, 0, newItem); targetElement.dataset[arrayKey] = JSON.stringify(arrayData); } catch (e) { throw createInvokerError('Invalid JSON in data-value attribute', ErrorSeverity.ERROR, { command: '--data:set:array:insert', element: invoker }); } }, /** * `--data:set:array:sort`: Sorts an array stored in a data attribute by a specified property. * @example `<button command="--data:set:array:sort:todos" data-sort-by="title" data-sort-order="asc" commandfor="#app">Sort by Title</button>` */ "--data:set:array:sort": ({ invoker, targetElement, params }: CommandContext) => { const arrayKey = params[0]; if (!arrayKey) { throw createInvokerError('Array sort command requires an array key parameter', ErrorSeverity.ERROR, { command: '--data:set:array:sort', element: invoker }); } const sortBy = invoker.dataset.sortBy || invoker.dataset.sort_by; const sortOrder = invoker.dataset.sortOrder || invoker.dataset.sort_order || 'asc'; if (!sortBy) { throw createInvokerError('Array sort command requires a data-sort-by attribute', ErrorSeverity.ERROR, { command: '--data:set:array:sort', element: invoker }); } let arrayData: any[] = []; try { const existingData = targetElement.dataset[arrayKey]; arrayData = existingData ? JSON.parse(existingData) : []; } catch (e) { arrayData = []; } arrayData.sort((a, b) => { const aVal = a[sortBy]; const bVal = b[sortBy]; let comparison = 0; if (aVal < bVal) comparison = -1; else if (aVal > bVal) comparison = 1; return sortOrder === 'desc' ? -comparison : comparison; }); targetElement.dataset[arrayKey] = JSON.stringify(arrayData); }, /** * `--data:set:array:filter`: Filters an array stored in a data attribute and stores the result in a new key. * @example `<button command="--data:set:array:filter:todos" data-filter-by="completed" data-filter-value="false" data-result-key="filtered-todos" commandfor="#app">Show Pending</button>` */ "--data:set:array:filter": ({ invoker, targetElement, params }: CommandContext) => { const arrayKey = params[0]; if (!arrayKey) { throw createInvokerError('Array filter command requires an array key parameter', ErrorSeverity.ERROR, { command: '--data:set:array:filter', element: invoker }); } const filterBy = invoker.dataset.filterBy || invoker.dataset.filter_by; const filterValue = invoker.dataset.filterValue || invoker.dataset.filter_value; const resultKey = invoker.dataset.resultKey || invoker.dataset.result_key || `${arrayKey}-filtered`; if (!filterBy) { throw createInvokerError('Array filter command requires a data-filter-by attribute', ErrorSeverity.ERROR, { command: '--data:set:array:filter', element: invoker }); } let arrayData: any[] = []; try { const existingData = targetElement.dataset[arrayKey]; arrayData = existingData ? JSON.parse(existingData) : []; } catch (e) { arrayData = []; } const filteredData = arrayData.filter(item => { const itemValue = item[filterBy]; if (filterValue === 'true') return itemValue === true; if (filterValue === 'false') return itemValue === false; return String(itemValue) === filterValue; }); targetElement.dataset[resultKey] = JSON.stringify(filteredData); }, // --- Application-Specific Todo Commands --- // These are specialized commands that could be extracted to a separate module in the future /** * `--data:set:new-todo`: Adds a new todo item to the todos array. * @example `<form command="--data:set:new-todo" data-bind-to="#form-data" data-bind-as="data:new-todo-json">` */ "--data:set:new-todo": ({ invoker, targetElement }: CommandContext) => { // Get the form data const formData = getFormData(invoker as unknown as HTMLFormElement); // Generate unique ID and add metadata const newTodo = { id: generateId(), title: formData.title || '', description: formData.description || '', priority: formData.priority || 'medium', tags: formData.tags || '', completed: false, created: new Date().toLocaleDateString() }; let todos: any[] = []; try { const existingData = targetElement.dataset.todos; todos = existingData ? JSON.parse(existingData) : []; } catch (e) { todos = []; } todos.push(newTodo); targetElement.dataset.todos = JSON.stringify(todos); // Dispatch event for UI updates targetElement.dispatchEvent(new CustomEvent('todo-updated', { bubbles: true })); }, /** * `--data:set:toggle`: Toggles the completed status of a todo item. * @example `<input command="--data:set:toggle:123" data-bind-to="body" data-bind-as="data:toggle-item">` */ "--data:set:toggle": ({ invoker, targetElement, params }: CommandContext) => { const todoId = params[0]; if (!todoId) { throw createInvokerError('Toggle command requires a todo ID parameter', ErrorSeverity.ERROR, { command: '--data:set:toggle', element: invoker }); } let todos: any[] = []; try { const existingData = targetElement.dataset.todos; todos = existingData ? JSON.parse(existingData) : []; } catch (e) { todos = []; } const todoIndex = todos.findIndex(t => t.id === todoId); if (todoIndex !== -1) { todos[todoIndex].completed = !todos[todoIndex].completed; targetElement.dataset.todos = JSON.stringify(todos); // Dispatch event for UI updates targetElement.dispatchEvent(new CustomEvent('todo-updated', { bubbles: true })); } }, /** * `--data:set:delete`: Deletes a todo item. * @example `<button command="--data:set:delete:123" data-bind-to="body" data-bind-as="data:delete-item">` */ "--data:set:delete": ({ invoker, targetElement, params }: CommandContext) => { const todoId = params[0]; if (!todoId) { throw createInvokerError('Delete command requires a todo ID parameter', ErrorSeverity.ERROR, { command: '--data:set:delete', element: invoker }); } let todos: any[] = []; try { const existingData = targetElement.dataset.todos; todos = existingData ? JSON.parse(existingData) : []; } catch (e) { todos = []; } const filteredTodos = todos.filter(t => t.id !== todoId); targetElement.dataset.todos = JSON.stringify(filteredTodos); // Dispatch event for UI updates targetElement.dispatchEvent(new CustomEvent('todo-updated', { bubbles: true })); }, /** * `--data:set:bulk-action:complete-all`: Marks all pending todos as completed. * @example `<button command="--data:set:bulk-action:complete-all" data-bind-to="body" data-bind-as="data:bulk-action">` */ "--data:set:bulk-action:complete-all": ({ targetElement }: CommandContext) => { let todos: any[] = []; try { const existingData = targetElement.dataset.todos; todos = existingData ? JSON.parse(existingData) : []; } catch (e) { todos = []; } const updatedTodos = todos.map(todo => todo.completed ? todo : { ...todo, completed: true } ); targetElement.dataset.todos = JSON.stringify(updatedTodos); // Dispatch event for UI updates targetElement.dispatchEvent(new CustomEvent('todo-updated', { bubbles: true })); }, /** * `--data:set:bulk-action:clear-completed`: Removes all completed todos. * @example `<button command="--data:set:bulk-action:clear-completed" data-bind-to="body" data-bind-as="data:bulk-action">` */ "--data:set:bulk-action:clear-completed": ({ targetElement }: CommandContext) => { let todos: any[] = []; try { const existingData = targetElement.dataset.todos; todos = existingData ? JSON.parse(existingData) : []; } catch (e) { todos = []; } const filteredTodos = todos.filter(todo => !todo.completed); targetElement.dataset.todos = JSON.stringify(filteredTodos); // Dispatch event for UI updates targetElement.dispatchEvent(new CustomEvent('todo-updated', { bubbles: true })); }, /** * `--data:set:bulk-action:export`: Exports todos as JSON. * @example `<button command="--data:set:bulk-action:export" data-bind-to="body" data-bind-as="data:bulk-action">` */ "--data:set:bulk-action:export": ({ targetElement }: CommandContext) => { let todos: any[] = []; try { const existingData = targetElement.dataset.todos; todos = existingData ? JSON.parse(existingData) : []; } catch (e) { todos = []; } const dataStr = JSON.stringify(todos, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = `todos-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } }; // --- Helper Functions --- function getFormData(form: HTMLFormElement): Record<string, string> { const formData = new FormData(form); const data: Record<string, string> = {}; for (const [key, value] of formData.entries()) { data[key] = value.toString(); } return data; } function generateId(): string { return Date.now().toString(36) + Math.random().toString(36).substr(2); } /** * Registers all data manipulation and array operation commands with the InvokerManager. * This includes basic data operations, array manipulation, and reactive data binding. * * @param manager - The InvokerManager instance to register commands with * @example * ```javascript * import { registerDataCommands } from 'invokers/commands/data'; * import invokerManager from 'invokers'; * * registerDataCommands(invokerManager); * ``` */ export function registerDataCommands(manager: InvokerManager): void { for (const name in dataCommands) { if (dataCommands.hasOwnProperty(name)) { manager.register(name, dataCommands[name]); } } }