@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
JavaScript
// @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