UNPKG

@glyphtek/unspecd

Version:

A declarative UI framework for building internal tools and dashboards with TypeScript. Create interactive tables, forms, and dashboards using simple specifications.

1,341 lines (1,334 loc) 274 kB
// @bun var __create = Object.create; var __getProtoOf = Object.getPrototypeOf; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __toESM = (mod, isNodeMode, target) => { target = mod != null ? __create(__getProtoOf(mod)) : {}; const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; for (let key of __getOwnPropNames(mod)) if (!__hasOwnProp.call(to, key)) __defProp(to, key, { get: () => mod[key], enumerable: true }); return to; }; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); // node_modules/balanced-match/index.js var require_balanced_match = __commonJS((exports, module) => { module.exports = balanced; function balanced(a, b, str) { if (a instanceof RegExp) a = maybeMatch(a, str); if (b instanceof RegExp) b = maybeMatch(b, str); var r = range(a, b, str); return r && { start: r[0], end: r[1], pre: str.slice(0, r[0]), body: str.slice(r[0] + a.length, r[1]), post: str.slice(r[1] + b.length) }; } function maybeMatch(reg, str) { var m = str.match(reg); return m ? m[0] : null; } balanced.range = range; function range(a, b, str) { var begs, beg, left, right, result; var ai = str.indexOf(a); var bi = str.indexOf(b, ai + 1); var i = ai; if (ai >= 0 && bi > 0) { if (a === b) { return [ai, bi]; } begs = []; left = str.length; while (i >= 0 && !result) { if (i == ai) { begs.push(i); ai = str.indexOf(a, i + 1); } else if (begs.length == 1) { result = [begs.pop(), bi]; } else { beg = begs.pop(); if (beg < left) { left = beg; right = bi; } bi = str.indexOf(b, i + 1); } i = ai < bi && ai >= 0 ? ai : bi; } if (begs.length) { result = [left, right]; } } return result; } }); // node_modules/brace-expansion/index.js var require_brace_expansion = __commonJS((exports, module) => { var balanced = require_balanced_match(); module.exports = expandTop; var escSlash = "\x00SLASH" + Math.random() + "\x00"; var escOpen = "\x00OPEN" + Math.random() + "\x00"; var escClose = "\x00CLOSE" + Math.random() + "\x00"; var escComma = "\x00COMMA" + Math.random() + "\x00"; var escPeriod = "\x00PERIOD" + Math.random() + "\x00"; function numeric(str) { return parseInt(str, 10) == str ? parseInt(str, 10) : str.charCodeAt(0); } function escapeBraces(str) { return str.split("\\\\").join(escSlash).split("\\{").join(escOpen).split("\\}").join(escClose).split("\\,").join(escComma).split("\\.").join(escPeriod); } function unescapeBraces(str) { return str.split(escSlash).join("\\").split(escOpen).join("{").split(escClose).join("}").split(escComma).join(",").split(escPeriod).join("."); } function parseCommaParts(str) { if (!str) return [""]; var parts = []; var m = balanced("{", "}", str); if (!m) return str.split(","); var pre = m.pre; var body = m.body; var post = m.post; var p = pre.split(","); p[p.length - 1] += "{" + body + "}"; var postParts = parseCommaParts(post); if (post.length) { p[p.length - 1] += postParts.shift(); p.push.apply(p, postParts); } parts.push.apply(parts, p); return parts; } function expandTop(str) { if (!str) return []; if (str.substr(0, 2) === "{}") { str = "\\{\\}" + str.substr(2); } return expand(escapeBraces(str), true).map(unescapeBraces); } function embrace(str) { return "{" + str + "}"; } function isPadded(el) { return /^-?0\d/.test(el); } function lte(i, y) { return i <= y; } function gte(i, y) { return i >= y; } function expand(str, isTop) { var expansions = []; var m = balanced("{", "}", str); if (!m) return [str]; var pre = m.pre; var post = m.post.length ? expand(m.post, false) : [""]; if (/\$$/.test(m.pre)) { for (var k = 0;k < post.length; k++) { var expansion = pre + "{" + m.body + "}" + post[k]; expansions.push(expansion); } } else { var isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body); var isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body); var isSequence = isNumericSequence || isAlphaSequence; var isOptions = m.body.indexOf(",") >= 0; if (!isSequence && !isOptions) { if (m.post.match(/,.*\}/)) { str = m.pre + "{" + m.body + escClose + m.post; return expand(str); } return [str]; } var n; if (isSequence) { n = m.body.split(/\.\./); } else { n = parseCommaParts(m.body); if (n.length === 1) { n = expand(n[0], false).map(embrace); if (n.length === 1) { return post.map(function(p) { return m.pre + n[0] + p; }); } } } var N; if (isSequence) { var x = numeric(n[0]); var y = numeric(n[1]); var width = Math.max(n[0].length, n[1].length); var incr = n.length == 3 ? Math.abs(numeric(n[2])) : 1; var test = lte; var reverse = y < x; if (reverse) { incr *= -1; test = gte; } var pad = n.some(isPadded); N = []; for (var i = x;test(i, y); i += incr) { var c; if (isAlphaSequence) { c = String.fromCharCode(i); if (c === "\\") c = ""; } else { c = String(i); if (pad) { var need = width - c.length; if (need > 0) { var z = new Array(need + 1).join("0"); if (i < 0) c = "-" + z + c.slice(1); else c = z + c; } } } N.push(c); } } else { N = []; for (var j = 0;j < n.length; j++) { N.push.apply(N, expand(n[j], false)); } } for (var j = 0;j < N.length; j++) { for (var k = 0;k < post.length; k++) { var expansion = pre + N[j] + post[k]; if (!isTop || isSequence || expansion) expansions.push(expansion); } } } return expansions; } }); // src/lib/data-handler.ts async function invokeDataSource({ specFunctions, functionName, params, onPending, onFulfilled, onRejected }) { onPending(); try { if (!specFunctions || typeof specFunctions !== "object") { throw new Error("Invalid specFunctions: expected an object containing function implementations"); } if (!functionName || typeof functionName !== "string") { throw new Error("Invalid functionName: expected a non-empty string"); } const targetFunction = specFunctions[functionName]; if (!targetFunction) { throw new Error(`Function '${functionName}' not found in spec. Available functions: ${Object.keys(specFunctions).join(", ")}`); } if (typeof targetFunction !== "function") { throw new Error(`'${functionName}' exists in spec but is not a function. Got: ${typeof targetFunction}`); } const result = await targetFunction(params); onFulfilled(result); } catch (error) { let processedError; if (error instanceof Error) { processedError = error; } else { processedError = new Error(`Function '${functionName}' failed with: ${String(error)}`); } onRejected(processedError); } } // src/lib/theme.ts var theme = { panel: "bg-white border border-gray-200/60 rounded-xl shadow-lg shadow-gray-100/50 p-8 space-y-6 backdrop-blur-sm", title: "text-3xl font-bold mb-6 text-gray-900 bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent", description: "text-gray-700 leading-relaxed", label: "font-semibold text-gray-800 min-w-0 sm:min-w-[120px] flex-shrink-0 text-sm tracking-wide", textInput: "w-full px-4 py-3 border border-gray-300/80 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500 disabled:bg-gray-50/80 disabled:text-gray-500 transition-all duration-200 hover:border-gray-400/80", button: { primary: "bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold py-3 px-6 rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:ring-offset-2 shadow-lg shadow-blue-600/25 hover:shadow-xl hover:shadow-blue-700/30 transform hover:-translate-y-0.5", disabled: "bg-gray-400 cursor-not-allowed text-gray-600", secondary: "bg-gray-100 hover:bg-gray-200 disabled:bg-gray-50 disabled:cursor-not-allowed text-gray-700 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2", danger: "bg-red-600 hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2" }, feedback: { loading: "text-gray-500 animate-pulse", error: "text-red-600 p-4 border border-red-300 rounded-lg bg-red-50", success: "text-green-600 p-4 border border-green-300 rounded-lg bg-green-50", warning: "text-amber-600 p-4 border border-amber-300 rounded-lg bg-amber-50", info: "text-blue-600 p-4 border border-blue-300 rounded-lg bg-blue-50" }, data: { container: "space-y-3 bg-white border border-gray-200 rounded-lg p-4", field: "flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2 py-2 border-b border-gray-100 last:border-b-0", value: "text-gray-900 flex-1 break-words", emptyValue: "text-gray-400 italic flex-1", noData: "text-gray-500 italic p-4 bg-gray-50 border border-gray-200 rounded" }, layout: { spacing: "space-y-4", container: "max-w-4xl mx-auto", grid: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" }, text: { xl: "text-xl font-semibold text-gray-900", lg: "text-lg font-medium text-gray-900", base: "text-base text-gray-700", sm: "text-sm text-gray-600", xs: "text-xs text-gray-500" } }; // src/lib/components/smart-form.ts async function renderSmartFormComponent(content, specFunctions, targetElement) { targetElement.innerHTML = ""; const loadingElement = document.createElement("div"); loadingElement.className = theme.feedback.loading; loadingElement.textContent = "Loading form configuration..."; targetElement.appendChild(loadingElement); try { const [initialData, fieldOptions] = await Promise.all([ loadInitialFormData(content, specFunctions), loadDynamicFieldOptions(content, specFunctions) ]); targetElement.innerHTML = ""; renderFormWithData(content, initialData, targetElement, specFunctions, fieldOptions); console.log("Smart form rendered successfully with", content.formConfig.fields.length, "fields"); if (initialData) { console.log("Form pre-populated with initial data:", initialData); } if (Object.keys(fieldOptions).length > 0) { console.log("Dynamic options loaded for fields:", Object.keys(fieldOptions)); } } catch (error) { targetElement.innerHTML = ""; const errorContainer = document.createElement("div"); errorContainer.className = theme.feedback.error; const errorTitle = document.createElement("strong"); errorTitle.textContent = "Failed to Load Form Configuration"; errorTitle.className = "block font-semibold mb-2"; const errorMessage = document.createElement("p"); errorMessage.textContent = error instanceof Error ? error.message : "Unknown error occurred"; errorMessage.className = "text-sm"; errorContainer.appendChild(errorTitle); errorContainer.appendChild(errorMessage); targetElement.appendChild(errorContainer); console.error("Form loading failed:", error); } } async function loadInitialFormData(content, specFunctions) { if (!content.dataLoader) { return null; } return new Promise((resolve, reject) => { invokeDataSource({ specFunctions, functionName: content.dataLoader.functionName, params: {}, onPending: () => { console.log(`Loading form data using function: ${content.dataLoader.functionName}`); }, onFulfilled: (data) => { resolve(data); }, onRejected: (error) => { reject(new Error(`Failed to load form data: ${error.message}`)); } }); }); } async function loadDynamicFieldOptions(content, specFunctions) { const fieldsWithDynamicOptions = content.formConfig.fields.filter((field) => field.editorOptions?.optionsLoader); if (fieldsWithDynamicOptions.length === 0) { return {}; } console.log(`Loading dynamic options for ${fieldsWithDynamicOptions.length} fields`); const optionsPromises = fieldsWithDynamicOptions.map((field) => { const optionsLoader = field.editorOptions.optionsLoader; return new Promise((resolve, reject) => { invokeDataSource({ specFunctions, functionName: optionsLoader.functionName, params: optionsLoader.params || {}, onPending: () => { console.log(`Loading options for field '${field.field}' using function: ${optionsLoader.functionName}`); }, onFulfilled: (options) => { const optionsArray = Array.isArray(options) ? options : []; resolve({ fieldName: field.field, options: optionsArray }); }, onRejected: (error) => { reject(new Error(`Failed to load options for field '${field.field}': ${error.message}`)); } }); }); }); const loadedOptions = await Promise.all(optionsPromises); const fieldOptions = {}; for (const { fieldName, options } of loadedOptions) { fieldOptions[fieldName] = options; } return fieldOptions; } function gatherCheckboxValue(fieldName) { const checkboxContainer = document.querySelector(`#field-${fieldName}`); if (checkboxContainer) { const checkbox = checkboxContainer.querySelector('input[type="checkbox"]'); return checkbox ? checkbox.checked : false; } return false; } function gatherRadioValue(fieldName) { const selectedRadio = document.querySelector(`input[name="${fieldName}"]:checked`); return selectedRadio ? selectedRadio.value : null; } function gatherNumberValue(fieldName) { const numberElement = document.querySelector(`[name="${fieldName}"]`); if (!numberElement) { console.warn(`Could not find number field with name: ${fieldName}`); return null; } const numValue = numberElement.value; return numValue ? Number.parseFloat(numValue) : null; } function gatherSelectValue(fieldName) { const selectElement = document.querySelector(`[name="${fieldName}"]`); if (!selectElement) { console.warn(`Could not find select field with name: ${fieldName}`); return null; } return selectElement.value || null; } function gatherTextValue(fieldName) { const textElement = document.querySelector(`[name="${fieldName}"]`); if (!textElement) { console.warn(`Could not find text field with name: ${fieldName}`); return null; } const textValue = textElement.value.trim(); return textValue || null; } function gatherFieldValue(fieldConfig) { const fieldName = fieldConfig.field; switch (fieldConfig.editorType) { case "checkbox": return gatherCheckboxValue(fieldName); case "radio": return gatherRadioValue(fieldName); case "number": return gatherNumberValue(fieldName); case "select": return gatherSelectValue(fieldName); default: return gatherTextValue(fieldName); } } function renderFormWithData(content, initialData, targetElement, specFunctions, fieldOptions = {}) { const form = document.createElement("form"); form.className = `${theme.panel} max-w-2xl mx-auto`; form.setAttribute("novalidate", ""); const fieldsContainer = document.createElement("div"); fieldsContainer.className = "space-y-6"; content.formConfig.fields.forEach((fieldConfig) => { const fieldValue = initialData ? initialData[fieldConfig.field] : undefined; const dynamicOptions = fieldOptions[fieldConfig.field]; const fieldContainer = renderFormField(fieldConfig, fieldValue, dynamicOptions); fieldsContainer.appendChild(fieldContainer); }); form.appendChild(fieldsContainer); const submitButtonContainer = document.createElement("div"); submitButtonContainer.className = "pt-6 border-t border-gray-200/60 mt-8"; const submitButton = document.createElement("button"); submitButton.type = "submit"; submitButton.textContent = content.formConfig.submitButton?.label || "Submit"; submitButton.className = theme.button.primary; submitButtonContainer.appendChild(submitButton); form.appendChild(submitButtonContainer); form.addEventListener("submit", async (event) => { event.preventDefault(); const formData = {}; content.formConfig.fields.forEach((fieldConfig) => { const fieldValue = gatherFieldValue(fieldConfig); if (fieldValue !== null) { formData[fieldConfig.field] = fieldValue; } }); console.log("Form data gathered:", formData); await invokeDataSource({ specFunctions, functionName: content.onSubmit.functionName, params: { formData, originalData: initialData }, onPending: () => { submitButton.disabled = true; submitButton.textContent = "Submitting..."; submitButton.className = submitButton.className.replace("bg-blue-600 hover:bg-blue-700", "bg-gray-400 cursor-not-allowed"); console.log("Form submission started"); }, onFulfilled: (response) => { resetSubmitButton(submitButton, content); const successMessage = processSuccessMessage(response); window.alert(successMessage); handleRedirect(content); console.log("Form submitted successfully:", response); }, onRejected: (error) => { resetSubmitButton(submitButton, content); window.alert(`Form submission failed: ${error.message}`); console.error("Form submission failed:", error); } }); }); targetElement.appendChild(form); } function createFormFieldLabel(fieldConfig) { const label = document.createElement("label"); label.textContent = fieldConfig.label; if (fieldConfig.required) { label.textContent += " *"; } label.className = `${theme.label} block`; return label; } function configureCheckboxInput(inputElement, fieldConfig, initialValue, label) { const checkbox = inputElement.querySelector('input[type="checkbox"]'); if (checkbox) { checkbox.setAttribute("name", fieldConfig.field); checkbox.id = `field-${fieldConfig.field}`; label.setAttribute("for", checkbox.id); if (fieldConfig.required) { checkbox.setAttribute("required", ""); } if (fieldConfig.disabled) { checkbox.setAttribute("disabled", ""); } const valueToSet = initialValue !== undefined ? initialValue : fieldConfig.defaultValue; if (valueToSet !== undefined && valueToSet) { checkbox.checked = true; } } } function configureRadioInput(inputElement, fieldConfig, initialValue) { const valueToSet = initialValue !== undefined ? initialValue : fieldConfig.defaultValue; if (valueToSet !== undefined) { const radioToCheck = inputElement.querySelector(`input[value="${valueToSet}"]`); if (radioToCheck) { radioToCheck.checked = true; } } } function configureRegularInput(inputElement, fieldConfig, initialValue, label) { inputElement.setAttribute("name", fieldConfig.field); label.setAttribute("for", inputElement.id); if (fieldConfig.required) { inputElement.setAttribute("required", ""); } if (fieldConfig.disabled) { inputElement.setAttribute("disabled", ""); } if (fieldConfig.isReadOnly) { inputElement.setAttribute("readonly", ""); } const valueToSet = initialValue !== undefined ? initialValue : fieldConfig.defaultValue; if (valueToSet !== undefined) { if (inputElement instanceof HTMLInputElement || inputElement instanceof HTMLTextAreaElement || inputElement instanceof HTMLSelectElement) { inputElement.value = String(valueToSet); } } if (fieldConfig.placeholder && "placeholder" in inputElement) { inputElement.placeholder = fieldConfig.placeholder; } } function createHelpText(fieldConfig) { if (fieldConfig.helpText) { const helpText = document.createElement("p"); helpText.textContent = fieldConfig.helpText; helpText.className = `${theme.text.sm} text-gray-500`; return helpText; } return null; } function renderFormField(fieldConfig, initialValue, dynamicOptions) { const fieldContainer = document.createElement("div"); fieldContainer.className = "space-y-2"; const label = createFormFieldLabel(fieldConfig); const inputElement = createInputElement(fieldConfig, dynamicOptions); if (inputElement) { inputElement.id = `field-${fieldConfig.field}`; if (fieldConfig.editorType === "checkbox") { configureCheckboxInput(inputElement, fieldConfig, initialValue, label); } else if (fieldConfig.editorType === "radio") { configureRadioInput(inputElement, fieldConfig, initialValue); } else { configureRegularInput(inputElement, fieldConfig, initialValue, label); } } fieldContainer.appendChild(label); if (inputElement) { fieldContainer.appendChild(inputElement); } const helpText = createHelpText(fieldConfig); if (helpText) { fieldContainer.appendChild(helpText); } return fieldContainer; } function createInputElement(fieldConfig, dynamicOptions) { const { editorType } = fieldConfig; switch (editorType) { case "text": case "email": case "password": case "url": case "tel": case "number": case "date": case "datetime-local": { const input = document.createElement("input"); input.type = editorType; input.className = theme.textInput; return input; } case "textarea": { const textarea = document.createElement("textarea"); textarea.className = `${theme.textInput} min-h-[100px] resize-vertical`; textarea.rows = 4; return textarea; } case "select": { const select = document.createElement("select"); select.className = theme.textInput; const defaultOption = document.createElement("option"); defaultOption.value = ""; defaultOption.textContent = "Select an option..."; select.appendChild(defaultOption); const optionsToUse = dynamicOptions || fieldConfig.options; if (optionsToUse) { optionsToUse.forEach((option) => { const optionElement = document.createElement("option"); optionElement.value = String(option.value); optionElement.textContent = option.label; select.appendChild(optionElement); }); } return select; } case "checkbox": { const checkboxContainer = document.createElement("div"); checkboxContainer.className = "flex items-center space-x-2"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.className = "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"; const checkboxLabel = document.createElement("span"); checkboxLabel.textContent = "Enable"; checkboxLabel.className = theme.text.base; checkboxContainer.appendChild(checkbox); checkboxContainer.appendChild(checkboxLabel); return checkboxContainer; } case "radio": { const radioContainer = document.createElement("div"); radioContainer.className = "space-y-2"; const radioOptionsToUse = dynamicOptions || fieldConfig.options; if (radioOptionsToUse) { radioOptionsToUse.forEach((option, index) => { const radioOption = document.createElement("div"); radioOption.className = "flex items-center space-x-2"; const radio = document.createElement("input"); radio.type = "radio"; radio.name = fieldConfig.field; radio.value = String(option.value); radio.id = `${fieldConfig.field}-${index}`; radio.className = "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"; const radioLabel = document.createElement("label"); radioLabel.textContent = option.label; radioLabel.setAttribute("for", radio.id); radioLabel.className = `${theme.text.base} cursor-pointer`; radioOption.appendChild(radio); radioOption.appendChild(radioLabel); radioContainer.appendChild(radioOption); }); } return radioContainer; } default: console.warn(`Unsupported editorType: ${editorType}`); return null; } } function resetSubmitButton(submitButton, content) { submitButton.disabled = false; submitButton.textContent = content.formConfig.submitButton?.label || "Submit"; submitButton.className = theme.button.primary; } function processSuccessMessage(response) { let successMessage = "Form submitted successfully!"; if (response && typeof response === "object") { if (response.message) { successMessage = response.message; } else if (response.success && response.message) { successMessage = response.message; } } return successMessage; } function handleRedirect(content) { if (content.onSubmit.onSuccess?.redirect && content.onSubmit.onSuccess?.redirectUrl) { window.location.href = content.onSubmit.onSuccess.redirectUrl; } } // src/lib/components/smart-table.ts function normalizeColumns(columns) { return columns.map((column) => { if (typeof column === "string") { return { field: column, label: column.charAt(0).toUpperCase() + column.slice(1) }; } return column; }); } function renderSmartTableComponent(content, specFunctions, targetElement, getInputValues, setRefreshCallback) { targetElement.innerHTML = ""; const normalizedColumns = normalizeColumns(content.tableConfig.columns); let currentPage = 1; const pageSize = content.tableConfig.pagination?.defaultPageSize || 20; let sortBy = content.tableConfig.defaultSort?.field || ""; let sortDirection = content.tableConfig.defaultSort?.direction || "asc"; let editingRowId = null; let currentTableData = null; let isSaving = false; function handleSortClick(columnField) { if (sortBy === columnField) { sortDirection = sortDirection === "asc" ? "desc" : "asc"; } else { sortBy = columnField; sortDirection = "asc"; } fetchAndRenderPage(1); } function handleEditClick(itemId) { editingRowId = itemId; renderTableBody(); } function handleCancelClick() { editingRowId = null; isSaving = false; renderTableBody(); } async function handleSaveClick(itemId, originalItem) { if (!content.tableConfig.itemUpdater) { console.error("No itemUpdater function configured for editing"); return; } const row = document.querySelector(`tr[data-item-id="${itemId}"]`); if (!row) return; const changes = {}; let hasChanges = false; normalizedColumns.forEach((column) => { if (column.isEditable) { const input = row.querySelector(`input[data-field="${column.field}"], select[data-field="${column.field}"]`); if (input) { const newValue = input.value; const oldValue = String(originalItem[column.field] || ""); if (newValue !== oldValue) { changes[column.field] = newValue; hasChanges = true; } } } }); if (!hasChanges) { handleCancelClick(); return; } isSaving = true; renderTableBody(); await invokeDataSource({ specFunctions, functionName: content.tableConfig.itemUpdater.functionName, params: { itemId, changes, currentItem: originalItem }, onPending: () => { console.log(`Saving changes for item ${itemId}:`, changes); }, onFulfilled: (updatedItem) => { if (currentTableData?.items) { const itemIndex = currentTableData.items.findIndex((item) => item.id === itemId); if (itemIndex !== -1) { currentTableData.items[itemIndex] = updatedItem || { ...originalItem, ...changes }; } } editingRowId = null; isSaving = false; renderTableBody(); console.log("Item updated successfully:", updatedItem || changes); }, onRejected: (error) => { isSaving = false; renderTableBody(); window.alert(`Failed to save changes: ${error.message}`); console.error("Save operation failed:", error); } }); } function createTableCell(column, item, isEditing) { const td = document.createElement("td"); td.className = "px-6 py-4 whitespace-nowrap text-sm text-gray-900"; if (isEditing && column.isEditable) { const input = document.createElement("input"); input.type = "text"; input.value = String(item[column.field] || ""); input.setAttribute("data-field", column.field); input.className = `${theme.textInput} w-full`; td.appendChild(input); } else { const fieldValue = item[column.field]; if (fieldValue == null) { td.textContent = "\u2014"; td.className += " text-gray-400 italic"; } else { const displayValue = String(fieldValue); td.textContent = displayValue; if (displayValue.length > 50) { td.className += " truncate max-w-xs"; td.title = displayValue; } } } return td; } function createActionsCell(item, index, isEditing) { const actionsTd = document.createElement("td"); actionsTd.className = "px-6 py-4 whitespace-nowrap text-sm font-medium"; if (isEditing) { const buttonContainer = document.createElement("div"); buttonContainer.className = "flex space-x-2"; const saveButton = document.createElement("button"); saveButton.textContent = isSaving ? "Saving..." : "Save"; saveButton.className = theme.button.primary; saveButton.disabled = isSaving; if (isSaving) { saveButton.className = saveButton.className.replace("bg-blue-600 hover:bg-blue-700", "bg-gray-400 cursor-not-allowed"); } saveButton.addEventListener("click", () => { handleSaveClick(item.id || index, item); }); const cancelButton = document.createElement("button"); cancelButton.textContent = "Cancel"; cancelButton.className = theme.button.secondary; cancelButton.disabled = isSaving; if (isSaving) { cancelButton.className = cancelButton.className.replace("bg-gray-100 hover:bg-gray-200", "bg-gray-50 cursor-not-allowed"); } cancelButton.addEventListener("click", handleCancelClick); buttonContainer.appendChild(saveButton); buttonContainer.appendChild(cancelButton); actionsTd.appendChild(buttonContainer); } else { const editButton = document.createElement("button"); editButton.textContent = "Edit"; editButton.className = theme.button.secondary; editButton.addEventListener("click", () => { handleEditClick(item.id || index); }); actionsTd.appendChild(editButton); } return actionsTd; } function createTableRow(item, index) { const row = document.createElement("tr"); row.className = index % 2 === 0 ? "bg-white" : "bg-gray-50"; row.setAttribute("data-item-id", item.id || index); const isEditing = editingRowId === (item.id || index); normalizedColumns.forEach((column) => { const td = createTableCell(column, item, isEditing); row.appendChild(td); }); if (content.tableConfig.itemUpdater) { const actionsTd = createActionsCell(item, index, isEditing); row.appendChild(actionsTd); } return row; } function renderTableBody(tbody) { const tableBody = tbody || document.querySelector("#unspecd-table-body"); if (!tableBody || !currentTableData) { console.warn("Table body element not found or no data available"); return; } tableBody.innerHTML = ""; currentTableData.items.forEach((item, index) => { const row = createTableRow(item, index); tableBody.appendChild(row); }); } function createHeaderCell(column) { const th = document.createElement("th"); th.className = "px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"; if (column.isSortable) { const sortButton = document.createElement("button"); sortButton.className = "hover:text-gray-700 focus:outline-none focus:text-gray-700 transition-colors duration-200"; let buttonText = column.label; if (sortBy === column.field) { buttonText += sortDirection === "asc" ? " \u25B2" : " \u25BC"; } sortButton.textContent = buttonText; sortButton.addEventListener("click", () => { handleSortClick(column.field); }); th.appendChild(sortButton); } else { th.textContent = column.label; } return th; } function createActionsHeaderCell() { const actionsTh = document.createElement("th"); actionsTh.className = "px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"; actionsTh.textContent = "Actions"; return actionsTh; } function validateTableData(data) { return data && Array.isArray(data.items); } function renderInvalidDataError() { const errorElement = document.createElement("div"); errorElement.className = theme.feedback.error; const errorTitle = document.createElement("strong"); errorTitle.textContent = "Invalid Data Format"; errorTitle.className = "block font-semibold mb-2"; const errorMessage = document.createElement("p"); errorMessage.textContent = "Expected data format: { items: any[], totalItems: number }"; errorMessage.className = "text-sm"; errorElement.appendChild(errorTitle); errorElement.appendChild(errorMessage); targetElement.appendChild(errorElement); } function renderEmptyDataState() { const noDataElement = document.createElement("div"); noDataElement.className = theme.data.noData; noDataElement.textContent = "No data available"; targetElement.appendChild(noDataElement); } function createTableStructure(data) { const tableContainer = document.createElement("div"); tableContainer.className = "overflow-x-auto bg-white border border-gray-200 rounded-lg shadow-sm"; const table = document.createElement("table"); table.className = "min-w-full divide-y divide-gray-200"; const thead = document.createElement("thead"); thead.className = "bg-gray-50"; const headerRow = document.createElement("tr"); normalizedColumns.forEach((column) => { const th = createHeaderCell(column); headerRow.appendChild(th); }); if (content.tableConfig.itemUpdater) { const actionsTh = createActionsHeaderCell(); headerRow.appendChild(actionsTh); } thead.appendChild(headerRow); table.appendChild(thead); currentTableData = data; const tbody = document.createElement("tbody"); tbody.className = "bg-white divide-y divide-gray-200"; tbody.id = "table-body"; table.appendChild(tbody); return { tableContainer, table, tbody }; } function createTableFooter(data) { const footerInfo = document.createElement("div"); footerInfo.className = "px-6 py-3 bg-gray-50 border-t border-gray-200 text-sm text-gray-700"; const totalText = data.totalItems !== undefined ? `Showing ${data.items.length} of ${data.totalItems} items` : `Showing ${data.items.length} items`; footerInfo.textContent = totalText; return footerInfo; } function renderCompleteTable(data) { const { tableContainer, table, tbody } = createTableStructure(data); renderTableBody(tbody); tableContainer.appendChild(table); const footerInfo = createTableFooter(data); tableContainer.appendChild(footerInfo); targetElement.appendChild(tableContainer); if (data.totalItems && data.totalItems > pageSize) { const totalPages = Math.ceil(data.totalItems / pageSize); renderPaginationControls(totalPages); } console.log("Table rendered successfully:", { items: data.items.length, totalItems: data.totalItems, currentPage, sortBy, sortDirection }); } async function fetchAndRenderPage(page) { currentPage = page; const params = { page: currentPage, pageSize, sortBy, sortDirection, ...getInputValues ? getInputValues() : {} }; await invokeDataSource({ specFunctions, functionName: content.dataLoader.functionName, params, onPending: () => { targetElement.innerHTML = ""; const loadingElement = document.createElement("p"); loadingElement.textContent = "Loading table data..."; loadingElement.className = theme.feedback.loading; targetElement.appendChild(loadingElement); console.log(`Loading table data using function: ${content.dataLoader.functionName}, page: ${page}`); }, onFulfilled: (data) => { targetElement.innerHTML = ""; if (!validateTableData(data)) { renderInvalidDataError(); return; } if (data.items.length === 0) { renderEmptyDataState(); return; } renderCompleteTable(data); }, onRejected: (error) => { targetElement.innerHTML = ""; const errorContainer = document.createElement("div"); errorContainer.className = theme.feedback.error; const errorTitle = document.createElement("strong"); errorTitle.textContent = "Failed to Load Table Data"; errorTitle.className = "block font-semibold mb-2"; const errorMessage = document.createElement("p"); errorMessage.textContent = error.message; errorMessage.className = "text-sm"; errorContainer.appendChild(errorTitle); errorContainer.appendChild(errorMessage); targetElement.appendChild(errorContainer); console.error("Smart Table Component Error:", error); } }); } function renderPaginationControls(totalPages) { const paginationContainer = document.createElement("div"); paginationContainer.className = "flex items-center justify-between px-6 py-4 bg-white border-t border-gray-200"; const pageInfo = document.createElement("span"); pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; pageInfo.className = "text-sm text-gray-700"; const buttonContainer = document.createElement("div"); buttonContainer.className = "flex space-x-2"; const prevButton = document.createElement("button"); prevButton.textContent = "Previous"; prevButton.className = theme.button.primary; if (currentPage === 1) { prevButton.disabled = true; prevButton.className = prevButton.className.replace("bg-blue-600 hover:bg-blue-700", "bg-gray-400 cursor-not-allowed"); } prevButton.addEventListener("click", async () => { if (currentPage > 1) { await fetchAndRenderPage(currentPage - 1); } }); const nextButton = document.createElement("button"); nextButton.textContent = "Next"; nextButton.className = theme.button.primary; if (currentPage === totalPages) { nextButton.disabled = true; nextButton.className = nextButton.className.replace("bg-blue-600 hover:bg-blue-700", "bg-gray-400 cursor-not-allowed"); } nextButton.addEventListener("click", async () => { if (currentPage < totalPages) { await fetchAndRenderPage(currentPage + 1); } }); buttonContainer.appendChild(prevButton); buttonContainer.appendChild(nextButton); paginationContainer.appendChild(pageInfo); paginationContainer.appendChild(buttonContainer); targetElement.appendChild(paginationContainer); } if (setRefreshCallback) { setRefreshCallback(() => fetchAndRenderPage(1)); } fetchAndRenderPage(1); } // src/lib/components/streaming-table.ts async function renderStreamingTableComponent(content, _specFunctions, targetElement) { targetElement.innerHTML = ""; const tableContainer = document.createElement("div"); tableContainer.className = "bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden"; const statusBar = document.createElement("div"); statusBar.className = "px-4 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between"; statusBar.innerHTML = ` <div class="flex items-center space-x-2"> <div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse" id="connection-indicator"></div> <span class="text-sm text-gray-600" id="status-text">Connecting to live feed...</span> </div> <div class="text-xs text-gray-500" id="row-count">0 rows</div> `; const table = document.createElement("table"); table.className = "w-full"; const thead = document.createElement("thead"); thead.className = "bg-gray-50"; const headerRow = document.createElement("tr"); content.tableConfig.columns.forEach((column) => { const th = document.createElement("th"); th.className = "px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"; th.textContent = column.label; if (column.width) { th.style.width = column.width; } headerRow.appendChild(th); }); thead.appendChild(headerRow); table.appendChild(thead); const tbody = document.createElement("tbody"); tbody.className = "bg-white divide-y divide-gray-200"; tbody.id = "streaming-table-body"; table.appendChild(tbody); tableContainer.appendChild(statusBar); tableContainer.appendChild(table); targetElement.appendChild(tableContainer); const rowData = new Map; let _unsubscribeFunction = null; function formatValue(value, formatter) { if (value == null) return "N/A"; switch (formatter?.toLowerCase()) { case "currency": return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(Number(value)); case "datetime": return new Date(value).toLocaleString(); case "date": return new Date(value).toLocaleDateString(); case "time": return new Date(value).toLocaleTimeString(); default: return String(value); } } function createTableRow(item) { const row = document.createElement("tr"); row.className = "hover:bg-gray-50 transition-colors duration-150"; row.setAttribute("data-row-id", item[content.tableConfig.rowIdentifier]); content.tableConfig.columns.forEach((column) => { const cell = document.createElement("td"); cell.className = "px-4 py-3 text-sm text-gray-900"; cell.textContent = formatValue(item[column.field], column.formatter); row.appendChild(cell); }); return row; } function updateRowCount() { const rowCountElement = document.getElementById("row-count"); if (rowCountElement) { const count = rowData.size; rowCountElement.textContent = `${count} row${count !== 1 ? "s" : ""}`; } } function highlightNewRow(row) { if (content.tableConfig.streamingOptions?.highlightNewRows) { row.classList.add("bg-blue-50", "border-l-4", "border-blue-400"); setTimeout(() => { row.classList.remove("bg-blue-50", "border-l-4", "border-blue-400"); }, 3000); } } function animateRowUpdate(row) { if (content.tableConfig.streamingOptions?.showUpdateAnimations) { row.classList.add("bg-yellow-50"); setTimeout(() => { row.classList.remove("bg-yellow-50"); }, 1000); } } function enforceMaxRows() { const maxRows = content.tableConfig.streamingOptions?.maxRows; if (maxRows && rowData.size > maxRows) { const rowsToRemove = Array.from(rowData.keys()).slice(0, rowData.size - maxRows); rowsToRemove.forEach((rowId) => { rowData.delete(rowId); const rowElement = tbody.querySelector(`[data-row-id="${rowId}"]`); if (rowElement) { rowElement.remove(); } }); } } const onData = (event) => { console.log("\uD83D\uDCCA Streaming event received:", event); switch (event.type) { case "add": { const item = event.item; const rowId = item[content.tableConfig.rowIdentifier]; rowData.set(rowId, item); const newRow = createTableRow(item); if (tbody.firstChild) { tbody.insertBefore(newRow, tbody.firstChild); } else { tbody.appendChild(newRow); } highlightNewRow(newRow); enforceMaxRows(); updateRowCount(); break; } case "update": { const { itemId, changes } = event; const existingItem = rowData.get(itemId); if (existingItem) { const updatedItem = { ...existingItem, ...changes }; rowData.set(itemId, updatedItem); const rowElement = tbody.querySelector(`[data-row-id="${itemId}"]`); if (rowElement) { const cells = rowElement.querySelectorAll("td"); content.tableConfig.columns.forEach((column, index) => { if (cells[index]) { cells[index].textContent = formatValue(updatedItem[column.field], column.formatter); } }); animateRowUpdate(rowElement); } } break; } case "delete": { const { itemId } = event; rowData.delete(itemId); const rowElement = tbody.querySelector(`[data-row-id="${itemId}"]`); if (rowElement) { rowElement.remove(); } updateRowCount(); break; } case "replace": { rowData.clear(); tbody.innerHTML = ""; event.items.forEach((item) => { const rowId = item[content.tableConfig.rowIdentifier]; rowData.set(rowId, item); const newRow = createTableRow(item); tbody.appendChild(newRow); }); updateRowCount(); break; } case "clear": { rowData.clear(); tbody.innerHTML = ""; updateRowCount(); break; } default: console.warn("Unknown streaming event type:", event.type); } }; const onError = (error) => { console.error("\u274C Streaming table error:", error); const statusText = document.getElementById("status-text"); const indicator = document.getElementById("connection-indicator"); if (statusText && indicator) { statusText.textContent = `Error: ${error.message}`; indicator.className = "w-2 h-2 bg-red-500 rounded-full"; } }; const onConnect = () => { console.log("\u2705 Streaming table connected"); const statusText = document.getElementById("status-text"); const indicator = document.getElementById("connection-indicator"); if (statusText && indicator) { statusText.textContent = "Connected to live feed"; indicator.className = "w-2 h-2 bg-green-500 rounded-full"; } }; const onDisconnect = () => { console.log("\u26A0\uFE0F Streaming table disconnected"); const statusText = document.getElementById("status-text"); const indicator = document.getElementById("connection-indicator"); if (statusText && indicator) { statusText.textContent = "Disconnected from live feed"; indicator.className = "w-2 h-2 bg-gray-400 rounded-full"; } }; try { const wsPort = window.location.port ? Number.parseInt(window.location.port) + 1 : 3001; const wsUrl = `ws://${window.location.hostname}:${wsPort}`; console.log(`\uD83D\uDD0C Connecting to WebSocket at ${wsUrl}`); const ws = new WebSocket(wsUrl); ws.onopen = () => { console.log("\uD83D\uDD0C WebSocket connected"); let toolId = ""; if (window.__UNSPECD_TOOLS_DATA__) { const tool = window.__UNSPECD_TOOLS_DATA__.tools.find((t) => t.functionNames.includes(content.dataSource.functionName)); if (tool) { toolId = tool.id; } } if (!toolId) { onError(new Error(`Could not find tool ID for function '${content.dataSource.functionName}'`)); return; } ws.send(JSON.stringify({ type: "start-stream", toolId, functionName: content.dataSource.functionName, params: {} })); }; ws.onmessage = (event) => { try { const message = JSON.parse(event.data); switch (message.type) { case "data": onData(message.event); break; case "error": onError(new Error(message.error)); break; case "connect": onConnect(); break; case "disconnect": onDisconnect(); break; default: console.warn("Unknown WebSocket message type:", message.type); } } catch (error) { onError(error instanceof Error ? error : new Error("Failed to parse WebSocket message")); } }; ws.onerror = (error) => { console.error("\uD83D\uDD0C WebSocket error:", error); onError(new Error("WebSocke