UNPKG

web-mojo

Version:

WEB-MOJO - A lightweight JavaScript framework for building data-driven web applications

844 lines (843 loc) 29.3 kB
import { V as View, d as dataFormatter } from "./WebApp-B2r2EDj7.js"; class DataView extends View { constructor(options = {}) { const { data, model, fields, columns, responsive, showEmptyValues, emptyValueText, ...viewOptions } = options; super({ tagName: "div", className: "data-view", ...viewOptions }); this.data = data || {}; this.fields = fields || []; this.model = model || null; if (this.model) { this.data = this.model; } this.dataViewOptions = { columns: columns || 2, responsive: responsive !== false, // Default to true showEmptyValues: showEmptyValues || false, emptyValueText: emptyValueText || "—", rowClass: "row g-3", itemClass: "data-view-item", labelClass: "data-view-label fw-semibold text-muted small text-uppercase", valueClass: "data-view-value" }; } /** * Lifecycle hook - prepare data and fields before rendering */ async onBeforeRender() { if (this.fields.length === 0 && this.getData()) { this.generateFieldsFromData(); } } /** * Override renderTemplate to generate HTML directly * @returns {string} Complete HTML string */ async renderTemplate() { const items = this.buildItemsHTML(); return ` <div class="${this.dataViewOptions.rowClass}"> ${items} </div> `; } /** * Auto-generate field definitions from data object with intelligent type inference */ generateFieldsFromData() { const dataObj = this.getData(); if (dataObj && typeof dataObj === "object") { this.fields = Object.keys(dataObj).map((key) => { const value = dataObj[key]; const fieldType = this.inferFieldType(value, key); const formatter = this.inferFormatter(value, key, fieldType); return { name: key, label: this.formatLabel(key), type: fieldType, format: formatter }; }); } } /** * Format field name into a readable label * @param {string} name - Field name * @returns {string} Formatted label */ formatLabel(name) { return name.replace(/([A-Z])/g, " $1").replace(/[_-]/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()).trim(); } /** * Infer field type from value and key with improved intelligence * @param {*} value - Field value * @param {string} key - Field key * @returns {string} Field type */ inferFieldType(value, key = "") { if (value === null || value === void 0) return "text"; const keyLower = key.toLowerCase(); const type = typeof value; if (keyLower.includes("date") || keyLower.includes("time") || keyLower.includes("created") || keyLower.includes("updated") || keyLower.includes("modified") || keyLower.includes("last_login") || keyLower.includes("expires") || keyLower.includes("last_activity")) { return "datetime"; } if (keyLower.includes("email") || keyLower.includes("mail")) { return "email"; } if (keyLower.includes("url") || keyLower.includes("link") || keyLower.includes("website") || keyLower.includes("homepage")) { return "url"; } if (keyLower.includes("phone") || keyLower.includes("tel") || keyLower.includes("mobile") || keyLower.includes("cell")) { return "phone"; } if (keyLower.includes("price") || keyLower.includes("cost") || keyLower.includes("amount") || keyLower.includes("fee") || keyLower.includes("salary") || keyLower.includes("revenue")) { return "currency"; } if (keyLower.includes("size") || keyLower.includes("bytes")) { return "filesize"; } if (keyLower.includes("percent") || keyLower.includes("rate") || keyLower.includes("ratio") && type === "number") { return "percent"; } if (type === "boolean") return "boolean"; if (type === "number") return "number"; if (type === "object") { if (Array.isArray(value)) return "array"; if (value && value.renditions) return "file"; if (this.shouldUseDataView(value, keyLower)) { return "dataview"; } return "object"; } if (type === "string") { if (value.includes("@") && value.includes(".")) return "email"; if (value.match(/^\d{4}-\d{2}-\d{2}/)) return "date"; if (value.match(/^https?:\/\//)) return "url"; if (value.match(/^\+?[\d\s\-\(\)]+$/)) return "phone"; } return "text"; } /** * Infer appropriate formatter based on type and context * @param {*} value - Field value * @param {string} key - Field key * @param {string} fieldType - Inferred field type * @returns {string|null} Formatter pipe string */ inferFormatter(value, key, fieldType) { const keyLower = key.toLowerCase(); const formatters = []; switch (fieldType) { case "datetime": if (keyLower.includes("time") && !keyLower.includes("date")) { formatters.push("time"); } else if (keyLower.includes("relative") || keyLower.includes("ago") || keyLower.includes("last_")) { formatters.push("relative"); } else if (keyLower.includes("created") || keyLower.includes("updated") || keyLower.includes("modified")) { formatters.push('date("MMM D, YYYY")'); } else { formatters.push('date("MMMM D, YYYY")'); } break; case "date": if (keyLower.includes("birth") || keyLower.includes("dob")) { formatters.push('date("MMMM D, YYYY")'); } else { formatters.push('date("MMM D, YYYY")'); } break; case "email": break; case "url": break; case "phone": formatters.push("phone"); break; case "currency": formatters.push("currency"); if (keyLower.includes("eur") || keyLower.includes("euro")) { formatters[formatters.length - 1] = 'currency("EUR")'; } else if (keyLower.includes("gbp") || keyLower.includes("pound")) { formatters[formatters.length - 1] = 'currency("GBP")'; } break; case "filesize": formatters.push("filesize"); break; case "percent": formatters.push("percent"); break; case "number": if (typeof value === "number") { if (keyLower.includes("count") || keyLower.includes("total") || keyLower.includes("followers") || keyLower.includes("views")) { if (value >= 1e3) { formatters.push("compact"); } else { formatters.push("number"); } } else if (keyLower.includes("score") || keyLower.includes("rating")) { formatters.push("number"); if (value % 1 !== 0) { formatters[formatters.length - 1] = "number(1)"; } } else if (keyLower.includes("version") || keyLower.includes("id")) { return null; } else { formatters.push("number"); } } break; case "boolean": break; case "text": if (typeof value === "string") { if (keyLower.includes("description") || keyLower.includes("content") || keyLower.includes("body")) { if (value.length > 200) { formatters.push("truncate(200)"); } else if (value.length > 100) { formatters.push("truncate(100)"); } } else if (keyLower.includes("summary") || keyLower.includes("excerpt")) { if (value.length > 150) { formatters.push("truncate(150)"); } } else if (keyLower.includes("name") || keyLower.includes("title") || keyLower.includes("label")) { formatters.push("capitalize"); if (value.length > 50) { formatters.unshift("truncate(50)"); } } else if (keyLower.includes("slug") || keyLower.includes("handle") || keyLower.includes("username")) { formatters.push("slug"); } else if (keyLower.includes("code") || keyLower.includes("token") || keyLower.includes("key")) { if (value.length > 20) { formatters.push("mask"); } } else { if (value.length > 100) { formatters.push("truncate(100)"); } } } break; case "array": case "object": break; case "dataview": break; default: if (typeof value === "string" && value.length > 100) { formatters.push("truncate(100)"); } break; } return formatters.length > 0 ? formatters.join("|") : null; } /** * Determine if an object should be displayed as nested DataView vs JSON * @param {object} value - Object value to check * @param {string} keyLower - Lowercase field key * @returns {boolean} True if should use DataView */ shouldUseDataView(value, keyLower) { if (!value || typeof value !== "object" || Array.isArray(value)) { return false; } const dataViewPatterns = [ "permissions", "perms", "access", "rights", "settings", "config", "configuration", "options", "profile", "info", "details", "data", "metadata", "meta", "attributes", "props", "preferences", "prefs", "user_data", "contact", "address", "location", "stats", "statistics", "metrics", "counts" ]; if (window.utils && window.utils.isObject(value) && value.id) { return true; } const matchesPattern = dataViewPatterns.some((pattern) => keyLower.includes(pattern)); if (matchesPattern) { const keys = Object.keys(value); if (keys.length >= 2 && keys.length <= 20) { const hasComplexNesting = keys.some( (k) => typeof value[k] === "object" && value[k] !== null && !Array.isArray(value[k]) && Object.keys(value[k]).length > 3 ); if (!hasComplexNesting) { return true; } } } return false; } /** * Get data object (handles both raw objects and Models) * @returns {object} Data object */ getData() { if (this.model && this.model.attributes) { return { ...this.model.attributes }; } return this.data || {}; } /** * Get field value with formatting support * @param {object} field - Field definition * @returns {*} Field value (formatted if specified) */ getFieldValue(field) { let value; if (this.model && typeof this.model.get === "function") { value = this.model.get(field.name); } else { value = this.getData()[field.name]; } if (field.format) { value = dataFormatter.pipe(value, field.format); } if (value === null || value === void 0 || value === "") { return this.dataViewOptions.showEmptyValues ? this.dataViewOptions.emptyValueText : null; } if (field.template) { const modelData = this.model ? this.model : this.data; return this.renderTemplateString(field.template, modelData); } return value; } /** * Render a template string with data from a model or object. * Replaces {{key}} and {{nested.key}} placeholders. * @param {string} templateString - The template string. * @param {object} data - The data object or model. * @returns {string} The rendered string. */ renderTemplateString(templateString, data) { if (!templateString || !data) { return ""; } return templateString.replace(/\{\{([^}]+)\}\}/g, (match, key) => { const trimmedKey = key.trim(); let value; const parts = trimmedKey.split("|"); const dataKey = parts[0]; const formatters = parts.slice(1).join("|"); if (this.model && typeof this.model.get === "function") { value = this.model.get(dataKey); } else { value = dataKey.split(".").reduce((o, i) => o ? o[i] : void 0, data); } if (formatters) { value = dataFormatter.pipe(value, formatters); } return value !== void 0 && value !== null ? value : ""; }); } /** * Generate column classes based on configuration * @param {object} field - Field definition * @returns {string} CSS classes */ getColumnClasses(field) { if (field.type === "array" || field.type === "object" || field.type === "dataview") { return "col-12"; } const colSize = field.columns || field.colSize || field.cols || Math.floor(12 / this.dataViewOptions.columns); if (this.dataViewOptions.responsive) { return `col-12 col-md-${colSize}`; } return `col-${colSize}`; } /** * Build HTML for all data items * @returns {string} Items HTML */ buildItemsHTML() { return this.fields.map((field) => this.buildItemHTML(field)).filter(Boolean).join(""); } /** * Build HTML for a single data item * @param {object} field - Field definition * @returns {string} Item HTML */ buildItemHTML(field) { const value = this.getFieldValue(field); if (value === null && !this.dataViewOptions.showEmptyValues) { return ""; } const label = field.label || this.formatLabel(field.name); const colClasses = this.getColumnClasses(field); return ` <div class="${colClasses}"> <div class="${this.dataViewOptions.itemClass}" data-field="${field.name}"> ${this.buildLabelHTML(label, field)} ${this.buildValueHTML(value, field)} </div> </div> `; } /** * Build label HTML * @param {string} label - Label text * @param {object} field - Field definition * @returns {string} Label HTML */ buildLabelHTML(label, field) { const labelClass = field.labelClass || this.dataViewOptions.labelClass; return `<div class="${labelClass}">${this.escapeHtml(label)}:</div>`; } /** * Build value HTML with type-specific formatting * @param {*} value - Field value * @param {object} field - Field definition * @returns {string} Value HTML */ buildValueHTML(value, field) { const valueClass = field.valueClass || this.dataViewOptions.valueClass; const displayValue = this.formatDisplayValue(value, field); return `<div class="${valueClass}">${displayValue}</div>`; } /** * Format value for display with enhanced type handling * @param {*} value - Formatted value from DataFormatter (or raw if no format) * @param {object} field - Field definition * @returns {string} Formatted display value with HTML markup */ formatDisplayValue(value, field) { if (value === null || value === void 0) { return this.dataViewOptions.emptyValueText; } if (field.template) { return String(value); } if (field.format) { const htmlSafeFormatters = [ "badge", "email", "url", "icon", "status", "image", "avatar", "phone", "highlight", "pre" ]; const pipes = dataFormatter.parsePipeString(field.format); const lastFormatter = pipes.length > 0 ? pipes[pipes.length - 1].name.toLowerCase() : null; if (lastFormatter && htmlSafeFormatters.includes(lastFormatter)) { return String(value); } return this.escapeHtml(String(value)); } const rawValue = this.getData()[field.name]; switch (field.type) { case "boolean": return rawValue ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>'; case "email": const emailStr = String(value); return `<a href="mailto:${this.escapeHtml(emailStr)}" class="text-decoration-none">${this.escapeHtml(emailStr)}</a>`; case "url": const urlStr = String(value); return `<a href="${this.escapeHtml(urlStr)}" target="_blank" rel="noopener" class="text-decoration-none">${this.escapeHtml(urlStr)} <i class="bi bi-box-arrow-up-right"></i></a>`; case "array": case "object": return this.formatAsJson(rawValue); case "dataview": return this.formatAsDataView(rawValue, field); case "phone": const phoneStr = String(value); const telHref = phoneStr.replace(/[^\d\+]/g, ""); return `<a href="tel:${telHref}" class="text-decoration-none">${this.escapeHtml(phoneStr)}</a>`; default: return this.escapeHtml(String(value)); } } /** * Format object/array values as styled JSON * @param {*} value - Object or array value * @returns {string} Formatted JSON HTML */ formatAsJson(value) { try { const jsonString = JSON.stringify(value, null, 2); const escapedJson = this.escapeHtml(jsonString); const lines = jsonString.split("\n").length; const isLarge = lines > 10 || jsonString.length > 500; const uniqueId = `json-${Math.random().toString(36).substr(2, 9)}`; if (isLarge) { const preview = JSON.stringify(value).substring(0, 100) + (JSON.stringify(value).length > 100 ? "..." : ""); const escapedPreview = this.escapeHtml(preview); return ` <div class="json-container"> <div class="d-flex align-items-center justify-content-between mb-1"> <small class="text-muted">${Array.isArray(value) ? "Array" : "Object"} (${lines} lines)</small> <div class="btn-group btn-group-sm" role="group"> <button type="button" class="btn btn-outline-secondary btn-sm json-toggle" data-bs-toggle="collapse" data-bs-target="#${uniqueId}" aria-expanded="false"> <i class="bi bi-eye"></i> Show </button> <button type="button" class="btn btn-outline-secondary btn-sm json-copy" data-json='${this.escapeHtml(jsonString)}' title="Copy JSON"> <i class="bi bi-clipboard"></i> </button> </div> </div> <div class="json-preview bg-light p-2 rounded small border" style="font-family: 'Courier New', monospace;"> <code class="text-muted">${escapedPreview}</code> </div> <div class="collapse mt-2" id="${uniqueId}"> <pre class="json-display p-3 rounded small mb-0" style="max-height: 400px; overflow-y: auto; white-space: pre-wrap; font-family: 'Courier New', monospace;"><code>${this.syntaxHighlightJson(escapedJson)}</code></pre> </div> </div> `; } else { return ` <div class="json-container"> <div class="d-flex align-items-center justify-content-between mb-1"> <small class="text-muted">${Array.isArray(value) ? "Array" : "Object"}</small> <button type="button" class="btn btn-outline-secondary btn-sm json-copy" data-json='${this.escapeHtml(jsonString)}' title="Copy JSON"> <i class="bi bi-clipboard"></i> </button> </div> <pre class="json-display bg-light p-2 rounded small mb-0 border" style="white-space: pre-wrap; font-family: 'Courier New', monospace;"><code>${this.syntaxHighlightJson(escapedJson)}</code></pre> </div> `; } } catch (error) { return `<span class="text-muted fst-italic">[Object: ${typeof value}] - Cannot display as JSON</span>`; } } /** * Apply basic syntax highlighting to JSON * @param {string} json - Escaped JSON string * @returns {string} JSON with basic syntax highlighting */ syntaxHighlightJson(json) { return json.replace(/("([^"\\]|\\.)*")\s*:/g, '<span style="color: #0969da;">$1</span>:').replace(/:\s*("([^"\\]|\\.)*")/g, ': <span style="color: #0a3069;">$1</span>').replace(/:\s*(true|false)/g, ': <span style="color: #8250df;">$1</span>').replace(/:\s*(null)/g, ': <span style="color: #656d76;">$1</span>').replace(/:\s*(-?\d+\.?\d*)/g, ': <span style="color: #0550ae;">$1</span>'); } /** * Bind events including JSON interaction handlers */ bindEvents() { super.bindEvents(); if (!this.element) return; this.element.addEventListener("click", (e) => { const fieldElement = e.target.closest("[data-field]"); if (fieldElement) { const fieldName = fieldElement.dataset.field; const field = this.fields.find((f) => f.name === fieldName); this.emit("field:click", { field, fieldName, element: fieldElement, event: e }); } if (e.target.closest(".json-copy")) { e.preventDefault(); e.stopPropagation(); this.handleJsonCopy(e.target.closest(".json-copy")); } if (e.target.closest(".json-toggle")) { this.handleJsonToggle(e.target.closest(".json-toggle")); } }); } /** * Handle copying JSON to clipboard * @param {HTMLElement} button - Copy button element */ handleJsonCopy(button) { const jsonData = button.getAttribute("data-json"); if (!jsonData) return; try { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(jsonData).then(() => { this.showCopyFeedback(button); }); } else { const textarea = document.createElement("textarea"); textarea.value = jsonData; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); this.showCopyFeedback(button); } } catch (error) { console.warn("Failed to copy JSON:", error); } } /** * Handle JSON toggle button state * @param {HTMLElement} button - Toggle button element */ handleJsonToggle(button) { const icon = button.querySelector("i"); const isExpanded = button.getAttribute("aria-expanded") === "true"; setTimeout(() => { if (isExpanded) { icon.className = "bi bi-eye-slash"; button.innerHTML = '<i class="bi bi-eye-slash"></i> Hide'; } else { icon.className = "bi bi-eye"; button.innerHTML = '<i class="bi bi-eye"></i> Show'; } }, 10); } /** * Show visual feedback for successful copy * @param {HTMLElement} button - Copy button element */ showCopyFeedback(button) { const originalIcon = button.querySelector("i").className; const icon = button.querySelector("i"); icon.className = "bi bi-check text-success"; button.classList.add("btn-success"); button.classList.remove("btn-outline-secondary"); setTimeout(() => { icon.className = originalIcon; button.classList.remove("btn-success"); button.classList.add("btn-outline-secondary"); }, 1e3); } /** * Format complex objects as nested DataView * @param {object} value - Object value to display as DataView * @param {object} field - Field definition * @returns {string} Formatted DataView HTML */ formatAsDataView(value, field) { if (!value || typeof value !== "object") { return `<span class="text-muted fst-italic">No data available</span>`; } try { const nestedView = new this.constructor({ data: value, columns: field.dataViewColumns || 2, showEmptyValues: field.showEmptyValues ?? true, emptyValueText: field.emptyValueText || "Not set", // Pass any other dataView-specific options from field config ...field.dataViewOptions || {} }); nestedView.onInit(); nestedView.generateFieldsFromData(); const nestedHtml = nestedView.buildItemsHTML(); return ` <div class="nested-dataview border rounded p-3 bg-light"> <div class="${nestedView.dataViewOptions.rowClass}"> ${nestedHtml} </div> </div> `; } catch (error) { console.error("Error creating nested DataView:", error); return `<span class="text-danger">Error displaying nested data</span>`; } } /** * Update the data and re-render * @param {object} newData - New data object * @returns {Promise<DataView>} Promise resolving to this instance */ async updateData(newData) { this.data = newData; if (this.model && typeof this.model.set === "function") { this.model.set(newData); } if (this.fields.length > 0 && !this.options.fields) { this.fields = []; } await this.render(); this.emit("data:updated", { data: newData }); return this; } /** * Update field configuration and re-render * @param {array} newFields - New field configuration * @returns {Promise<DataView>} Promise resolving to this instance */ async updateFields(newFields) { this.fields = newFields; await this.render(); this.emit("fields:updated", { fields: newFields }); return this; } /** * Update configuration and re-render * @param {object} newOptions - New configuration options * @returns {Promise<DataView>} Promise resolving to this instance */ async updateConfig(newOptions) { this.dataViewOptions = { ...this.dataViewOptions, ...newOptions }; await this.render(); this.emit("config:updated", { options: this.dataViewOptions }); return this; } /** * Refresh data from model if available * @returns {Promise<DataView>} Promise resolving to this instance */ async refresh() { if (this.model && typeof this.model.fetch === "function") { try { await this.model.fetch(); this.emit("data:refreshed", { model: this.model }); } catch (error) { this.emit("error", { error, message: "Failed to refresh data" }); throw error; } } return this; } /** * Get current data * @returns {object} Current data object */ getCurrentData() { return this.getData(); } /** * Get field definition by name * @param {string} name - Field name * @returns {object|null} Field definition */ getField(name) { return this.fields.find((field) => field.name === name) || null; } /** * Set custom format for a specific field * @param {string} fieldName - Name of the field * @param {string} format - Pipe format string (e.g., "currency|uppercase") * @returns {DataView} This instance for chaining */ setFieldFormat(fieldName, format) { const field = this.getField(fieldName); if (field) { field.format = format; } else { this.fields.push({ name: fieldName, label: this.formatLabel(fieldName), type: this.inferFieldType(this.getData()[fieldName], fieldName), format }); } return this; } /** * Add additional formatter to existing field format pipe chain * @param {string} fieldName - Name of the field * @param {string} formatter - Formatter to add (e.g., "uppercase", "truncate(50)") * @returns {DataView} This instance for chaining */ addFormatPipe(fieldName, formatter) { const field = this.getField(fieldName); if (field) { if (field.format) { field.format += `|${formatter}`; } else { field.format = formatter; } } return this; } /** * Clear custom format for a field (revert to auto-inferred format) * @param {string} fieldName - Name of the field * @returns {DataView} This instance for chaining */ clearFieldFormat(fieldName) { const field = this.getField(fieldName); if (field) { const data = this.getData(); field.format = this.inferFormatter(data[fieldName], fieldName, field.type); } return this; } /** * Get formatted value for a specific field without rendering * @param {string} fieldName - Name of the field * @param {*} value - Optional value to format (uses current data if not provided) * @returns {*} Formatted value */ getFormattedValue(fieldName, value = null) { const field = this.getField(fieldName); if (!field) return null; const targetValue = value !== null ? value : this.getData()[fieldName]; if (field.format && targetValue != null) { return dataFormatter.pipe(targetValue, field.format); } return targetValue; } /** * Set multiple field formats at once * @param {object} formats - Object mapping field names to format strings * @returns {DataView} This instance for chaining */ setFieldFormats(formats) { Object.entries(formats).forEach(([fieldName, format]) => { this.setFieldFormat(fieldName, format); }); return this; } /** * Get all current field formats as an object * @returns {object} Object mapping field names to their current formats */ getFieldFormats() { const formats = {}; this.fields.forEach((field) => { if (field.format) { formats[field.name] = field.format; } }); return formats; } /** * Set up model event listeners if model is provided */ onInit() { super.onInit(); if (this.model && typeof this.model.on === "function") { this.model.on("change", () => { this.render(); }); } } /** * Static factory method * @param {object} options - DataView options * @returns {DataView} New DataView instance */ static create(options = {}) { return new DataView(options); } } export { DataView as default }; //# sourceMappingURL=DataView-UjG66gmW.js.map