UNPKG

web-mojo

Version:

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

1,656 lines (1,655 loc) 57.9 kB
import { i as EventEmitter, r as rest, h as MOJOUtils } from "./WebApp-B2r2EDj7.js"; class Model { constructor(data = {}, options = {}) { this.endpoint = options.endpoint || this.constructor.endpoint || ""; this.id = data.id || null; this.attributes = { ...data }; this._ = this.attributes; this.originalAttributes = { ...data }; this.errors = {}; this.loading = false; this.rest = rest; this.options = { idAttribute: "id", timestamps: true, ...options }; } getContextValue(key) { return this.get(key); } /** * Get attribute value with support for dot notation and pipe formatting * @param {string} key - Attribute key with optional pipes (e.g., "name|uppercase") * @returns {*} Attribute value, possibly formatted */ get(key) { if (!key.includes(".") && !key.includes("|") && this[key] !== void 0) { if (typeof this[key] === "function") { return this[key](); } return this[key]; } return MOJOUtils.getContextData(this.attributes, key); } /** * Set attribute value(s) * @param {string|object} key - Attribute key or object of key-value pairs * @param {*} value - Attribute value (if key is string) * @param {object} options - Options (silent: true to not trigger change event) */ set(key, value, options = {}) { const previousAttributes = { ...this.attributes }; let hasChanged = false; if (typeof key === "object") { Object.assign(this.attributes, key); Object.assign(this, key); if (key.id !== void 0) { this.id = key.id; } hasChanged = JSON.stringify(previousAttributes) !== JSON.stringify(this.attributes); } else { if (key === "id") { this.id = value; hasChanged = true; } else { const oldValue = this.attributes[key]; this.attributes[key] = value; this[key] = value; hasChanged = oldValue !== value; } } if (hasChanged && !options.silent) { this.emit("change", this); if (typeof key === "string") { this.emit(`change:${key}`, value, this); } else { for (const [attr, val] of Object.entries(key)) { if (previousAttributes[attr] !== val) { this.emit(`change:${attr}`, val, this); } } } } } getData() { return this.attributes; } getId() { return this.id; } /** * Fetch model data from API with request deduplication and cancellation * @param {object} options - Request options * @param {number} options.debounceMs - Optional debounce delay in milliseconds * @returns {Promise} Promise that resolves with REST response */ async fetch(options = {}) { let url = options.url; if (!url) { const id = options.id || this.getId(); if (!id && this.options.requiresId !== false) { throw new Error("Model: ID is required for fetching"); } url = this.buildUrl(id); } const requestKey = JSON.stringify({ url, params: options.params }); if (options.debounceMs && options.debounceMs > 0) { return this._debouncedFetch(requestKey, options); } if (this.currentRequest && this.currentRequestKey !== requestKey) { console.info("Model: Cancelling previous request for new parameters"); this.abortController?.abort(); this.currentRequest = null; } if (this.currentRequest && this.currentRequestKey === requestKey) { console.info("Model: Duplicate request in progress, returning existing promise"); return this.currentRequest; } const now = Date.now(); const minInterval = 100; if (this.lastFetchTime && now - this.lastFetchTime < minInterval) { console.info("Model: Rate limited, skipping fetch"); return this; } this.loading = true; this.errors = {}; this.lastFetchTime = now; this.currentRequestKey = requestKey; this.abortController = new AbortController(); this.currentRequest = this._performFetch(url, options, this.abortController); try { const result = await this.currentRequest; return result; } catch (error) { if (error.name === "AbortError") { console.info("Model: Request was cancelled"); return this; } throw error; } finally { this.currentRequest = null; this.currentRequestKey = null; this.abortController = null; } } /** * Handle debounced fetch requests * @param {string} requestKey - Unique key for this request * @param {object} options - Fetch options * @returns {Promise} Promise that resolves with REST response */ async _debouncedFetch(requestKey, options) { if (this.debouncedFetchTimeout) { clearTimeout(this.debouncedFetchTimeout); } this.cancel(); return new Promise((resolve, reject) => { this.debouncedFetchTimeout = setTimeout(async () => { try { const result = await this.fetch({ ...options, debounceMs: 0 }); resolve(result); } catch (error) { reject(error); } }, options.debounceMs); }); } /** * Internal method to perform the actual fetch * @param {string} url - API endpoint URL * @param {object} options - Request options * @param {AbortController} abortController - Controller for request cancellation * @returns {Promise} Promise that resolves with REST response */ async _performFetch(url, options, abortController) { try { if (options.graph && (!options.params || !options.params.graph)) { if (!options.params) options.params = {}; options.params.graph = options.graph; } const response = await this.rest.GET(url, options.params, { signal: abortController.signal }); if (response.success) { if (response.data.status) { this.originalAttributes = { ...this.attributes }; this.set(response.data.data); this.errors = {}; } else { this.errors = response.data; } } else { this.errors = response.errors || {}; } return response; } catch (error) { if (error.name === "AbortError") { console.info("Model: Fetch was cancelled"); throw error; } this.errors = { fetch: error.message }; return { success: false, error: error.message, status: error.status || 500 }; } finally { this.loading = false; } } /** * Save model to API (create or update) * @param {object} data - Data to save to the model * @param {object} options - Request options * @returns {Promise} Promise that resolves with REST response */ async save(data, options = {}) { const isNew = !this.id; const method = isNew ? "POST" : "PUT"; const url = isNew ? this.buildUrl() : this.buildUrl(this.id); this.loading = true; this.errors = {}; try { const response = await this.rest[method](url, data, options.params); if (response.success) { if (response.data.status) { this.originalAttributes = { ...this.attributes }; this.set(response.data.data); this.errors = {}; } else { this.errors = response.data; } } else { this.errors = response.errors || {}; } return response; } catch (error) { return { success: false, error: error.message, status: error.status || 500 }; } finally { this.loading = false; } } /** * Delete model from API * @param {object} options - Request options * @returns {Promise} Promise that resolves with REST response */ async destroy(options = {}) { if (!this.id) { this.errors = { destroy: "Cannot destroy model without ID" }; return { success: false, error: "Cannot destroy model without ID", status: 400 }; } const url = this.buildUrl(this.id); this.loading = true; this.errors = {}; try { const response = await this.rest.DELETE(url, options.params); if (response.success) { this.attributes = {}; this.originalAttributes = {}; this.id = null; this.errors = {}; } else { this.errors = response.errors || {}; } return response; } catch (error) { this.errors = { destroy: error.message }; return { success: false, error: error.message, status: error.status || 500 }; } finally { this.loading = false; } } /** * Check if model has been modified * @returns {boolean} True if model has unsaved changes */ isDirty() { return JSON.stringify(this.attributes) !== JSON.stringify(this.originalAttributes); } /** * Get attributes that have changed since last save * @returns {object} Object containing only changed attributes */ getChangedAttributes() { const changed = {}; for (const [key, value] of Object.entries(this.attributes)) { if (this.originalAttributes[key] !== value) { changed[key] = value; } } return changed; } /** * Reset model to original state */ reset() { this.attributes = { ...this.originalAttributes }; this._ = this.attributes; this.errors = {}; } /** * Build URL for API requests * @param {string|number} id - Optional ID to append to URL * @returns {string} Complete API URL */ buildUrl(id = null) { let url = this.endpoint; if (id) { url = url.endsWith("/") ? `${url}${id}` : `${url}/${id}`; } return url; } /** * Convert model to JSON * @returns {object} Model attributes as plain object */ toJSON() { return { id: this.id, ...this.attributes }; } /** * Validate model attributes * @returns {boolean} True if valid, false if validation errors exist */ validate() { this.errors = {}; if (this.constructor.validations) { for (const [field, rules] of Object.entries(this.constructor.validations)) { this.validateField(field, rules); } } return Object.keys(this.errors).length === 0; } /** * Validate a single field * @param {string} field - Field name * @param {object|array} rules - Validation rules */ validateField(field, rules) { const value = this.get(field); const rulesArray = Array.isArray(rules) ? rules : [rules]; for (const rule of rulesArray) { if (typeof rule === "function") { const result = rule(value, this); if (result !== true) { this.errors[field] = result || `${field} is invalid`; break; } } else if (typeof rule === "object") { if (rule.required && (value === void 0 || value === null || value === "")) { this.errors[field] = rule.message || `${field} is required`; break; } if (rule.minLength && value && value.length < rule.minLength) { this.errors[field] = rule.message || `${field} must be at least ${rule.minLength} characters`; break; } if (rule.maxLength && value && value.length > rule.maxLength) { this.errors[field] = rule.message || `${field} must be no more than ${rule.maxLength} characters`; break; } if (rule.pattern && value && !rule.pattern.test(value)) { this.errors[field] = rule.message || `${field} format is invalid`; break; } } } } // EventEmitter API: on, off, once, emit (from mixin). /** * Static method to create and fetch a model by ID * @param {string|number} id - Model ID * @param {object} options - Options * @returns {Promise<RestModel>} Promise that resolves with fetched model */ static async find(id, options = {}) { const model = new this({}, options); await model.fetch({ id, ...options }); return model; } /** * Static method to create a new model with data * @param {object} data - Model data * @param {object} options - Options * @returns {RestModel} New model instance */ static create(data = {}, options = {}) { return new this(data, options); } /** * Cancel any active fetch request * @returns {boolean} True if a request was cancelled, false if no active request */ cancel() { if (this.currentRequest && this.abortController) { console.info("Model: Manually cancelling active request"); this.abortController.abort(); return true; } if (this.debouncedFetchTimeout) { clearTimeout(this.debouncedFetchTimeout); this.debouncedFetchTimeout = null; return true; } return false; } /** * Check if model has an active fetch request * @returns {boolean} True if fetch is in progress */ isFetching() { return !!this.currentRequest; } async showError(message) { await Dialog.alert(message, "Error", { size: "md", class: "text-danger" }); } } Object.assign(Model.prototype, EventEmitter); class Collection { constructor(options = {}, data = null) { if (Array.isArray(options)) { data = options; options = data || {}; } else { data = data || options.data || []; } this.ModelClass = options.ModelClass || Model; this.models = []; this.loading = false; this.errors = {}; this.meta = {}; this.rest = rest; if (data) { this.add(data); } this.params = { start: 0, size: options.size || 10, ...options.params }; this.endpoint = options.endpoint || this.ModelClass.endpoint || ""; if (!this.endpoint) { let tmp = new this.ModelClass(); this.endpoint = tmp.endpoint; } this.restEnabled = this.endpoint ? true : false; if (options.restEnabled !== void 0) { this.restEnabled = options.restEnabled; } this.options = { parse: true, reset: true, preloaded: false, ...options }; } getModelName() { return this.ModelClass.name; } /** * Fetch collection data from API * @param {object} additionalParams - Additional parameters to merge for this fetch only * @returns {Promise} Promise that resolves with REST response */ async fetch(additionalParams = {}) { const requestKey = JSON.stringify({ ...this.params, ...additionalParams }); if (this.currentRequest && this.currentRequestKey !== requestKey) { console.info("Collection: Cancelling previous request for new parameters"); this.abortController?.abort(); this.currentRequest = null; } if (this.currentRequest && this.currentRequestKey === requestKey) { console.info("Collection: Duplicate request in progress, returning existing promise"); return this.currentRequest; } const now = Date.now(); const minInterval = 100; if (this.options.rateLimiting && this.lastFetchTime && now - this.lastFetchTime < minInterval) { console.info("Collection: Rate limited, skipping fetch"); return { success: true, message: "Rate limited, skipping fetch", data: { data: this.toJSON() } }; } if (!this.restEnabled) { console.info("Collection: REST disabled, skipping fetch"); return { success: true, message: "REST disabled, skipping fetch", data: { data: this.toJSON() } }; } if (this.options.preloaded && this.models.length > 0) { console.info("Collection: Using preloaded data, skipping fetch"); return { success: true, message: "Using preloaded data, skipping fetch", data: { data: this.toJSON() } }; } const url = this.buildUrl(); this.loading = true; this.errors = {}; this.lastFetchTime = now; this.currentRequestKey = requestKey; this.abortController = new AbortController(); this.currentRequest = this._performFetch(url, additionalParams, this.abortController); try { const result = await this.currentRequest; return result; } catch (error) { if (error.name === "AbortError") { console.info("Collection: Request was cancelled"); return { success: false, error: "Request cancelled", status: 0 }; } return { success: false, error: error.message, status: error.status || 500 }; } finally { this.currentRequest = null; this.currentRequestKey = null; this.abortController = null; } } /** * Internal method to perform the actual fetch * @param {string} url - API endpoint URL * @param {object} additionalParams - Additional parameters * @param {AbortController} abortController - Controller for request cancellation * @returns {Promise} Promise that resolves with REST response */ async _performFetch(url, additionalParams, abortController) { const fetchParams = { ...this.params, ...additionalParams }; console.log("Fetching collection data from", url, fetchParams); try { this.emit("fetch:start"); const response = await this.rest.GET(url, fetchParams, { signal: abortController.signal }); if (response.success && response.data.status) { const data = this.options.parse ? this.parse(response) : response.data; if (this.options.reset || additionalParams.reset !== false) { this.reset(); } this.add(data, { silent: additionalParams.silent }); this.errors = {}; this.emit("fetch:success"); } else { if (response.data && response.data.error) { this.errors = response.data; this.emit("fetch:error", { message: response.data.error, error: response.data }); } else { this.errors = response.errors || {}; this.emit("fetch:error", { error: response.errors }); } } return response; } catch (error) { if (error.name === "AbortError") { console.info("Collection: Fetch was cancelled"); return { success: false, error: "Request cancelled", status: 0 }; } this.errors = { fetch: error.message }; this.emit("fetch:error", { message: error.message, error }); return { success: false, error: error.message, status: error.status || 500 }; } finally { this.loading = false; this.emit("fetch:end"); } } /** * Update collection parameters and optionally fetch new data * @param {object} newParams - Parameters to update * @param {boolean} autoFetch - Whether to automatically fetch after updating params * @param {number} debounceMs - Optional debounce delay in milliseconds * @returns {Promise} Promise that resolves with REST response if autoFetch=true, or collection if autoFetch=false */ async updateParams(newParams, autoFetch = false, debounceMs = 0) { return await this.setParams({ ...this.params, ...newParams }, autoFetch, debounceMs); } async setParams(newParams, autoFetch = false, debounceMs = 0) { this.params = newParams; if (autoFetch && this.restEnabled) { if (debounceMs > 0) { if (this.debouncedFetchTimeout) { clearTimeout(this.debouncedFetchTimeout); } this.cancel(); return new Promise((resolve, reject) => { this.debouncedFetchTimeout = setTimeout(async () => { try { const result = await this.fetch(); resolve(result); } catch (error) { reject(error); } }, debounceMs); }); } else { return this.fetch(); } } return Promise.resolve(this); } /** * Fetch a single model by ID * @param {string|number} id - Model ID to fetch * @param {object} options - Additional fetch options * @returns {Promise<Model|null>} Promise that resolves with model instance or null if not found */ async fetchOne(id, options = {}) { if (!id) { console.warn("Collection: fetchOne requires an ID"); return null; } if (!this.restEnabled) { console.info("Collection: REST disabled, cannot fetch single item"); return null; } try { const model = new this.ModelClass({ id }, { endpoint: this.endpoint, collection: this }); const response = await model.fetch(options); if (response.success) { if (options.addToCollection === true) { const existingModel = this.get(model.id); if (!existingModel) { this.add(model, { silent: options.silent }); } else if (options.merge !== false) { existingModel.set(model.attributes); } } return model; } else { console.warn("Collection: fetchOne failed -", response.error || "Unknown error"); return null; } } catch (error) { console.error("Collection: fetchOne error -", error.message); return null; } } /** * Download collection data in a specified format * @param {string} format - The format for the download (e.g., 'csv', 'json') * @param {object} options - Download options * @returns {Promise} Promise that resolves with the download result */ async download(format = "json", options = {}) { if (!this.restEnabled) { console.warn("Collection: REST is not enabled, cannot download from remote."); return { success: false, message: "Remote downloads are not enabled for this collection." }; } const url = this.buildUrl(); const downloadParams = { ...this.params }; delete downloadParams.start; delete downloadParams.size; downloadParams.download_format = format; const filename = `export-${this.getModelName().toLowerCase()}.${format}`; const contentTypes = { json: "application/json", csv: "text/csv" }; const acceptHeader = contentTypes[format] || "*/*"; return this.rest.download(url, downloadParams, { ...options, filename, headers: { "Accept": acceptHeader } }); } /** * Parse response data - override in subclasses for custom parsing * @param {object} response - API response * @returns {array} Array of model data objects */ parse(response) { if (response.data && Array.isArray(response.data.data)) { this.meta = { size: response.data.size || 10, start: response.data.start || 0, count: response.data.count || 0, status: response.data.status, graph: response.data.graph, ...response.meta }; return response.data.data; } if (Array.isArray(response.data)) { return response.data; } return Array.isArray(response) ? response : [response]; } /** * Add model(s) to the collection * @param {object|array} data - Model data or array of model data * @param {object} options - Options for adding models */ add(data, options = {}) { const modelsData = Array.isArray(data) ? data : [data]; const addedModels = []; for (const modelData of modelsData) { let model; if (modelData instanceof this.ModelClass) { model = modelData; } else { model = new this.ModelClass(modelData, { endpoint: this.endpoint, collection: this }); } const existingIndex = this.models.findIndex((m) => m.id === model.id); if (existingIndex !== -1) { if (options.merge !== false) { this.models[existingIndex].set(model.attributes); } } else { this.models.push(model); addedModels.push(model); } } if (!options.silent && addedModels.length > 0) { this.emit("add", { models: addedModels, collection: this }); this.emit("update", { collection: this }); } return addedModels; } /** * Remove model(s) from the collection * @param {Model|array|string|number} models - Model(s) to remove or ID(s) * @param {object} options - Options */ remove(models, options = {}) { const modelsToRemove = Array.isArray(models) ? models : [models]; const removedModels = []; for (const model of modelsToRemove) { let index = -1; if (typeof model === "string" || typeof model === "number") { index = this.models.findIndex((m) => m.id == model); } else { index = this.models.indexOf(model); } if (index !== -1) { const removedModel = this.models.splice(index, 1)[0]; removedModels.push(removedModel); } } if (!options.silent && removedModels.length > 0) { this.emit("remove", { models: removedModels, collection: this }); this.emit("update", { collection: this }); } return removedModels; } /** * Reset the collection (remove all models) * @param {array} models - Optional new models to set * @param {object} options - Options */ reset(models = null, options = {}) { const previousModels = [...this.models]; this.models = []; if (models) { this.add(models, { silent: true, ...options }); } if (!options.silent) { this.emit("reset", { collection: this, previousModels }); } return this; } /** * Get model by ID * @param {string|number} id - Model ID * @returns {Model|undefined} Model instance or undefined */ get(id) { return this.models.find((model) => model.id == id); } /** * Get model by index * @param {number} index - Model index * @returns {Model|undefined} Model instance or undefined */ at(index) { return this.models[index]; } /** * Get collection length * @returns {number} Number of models in collection */ length() { return this.models.length; } /** * Check if collection is empty * @returns {boolean} True if collection has no models */ isEmpty() { return this.models.length === 0; } /** * Find models matching criteria * @param {function|object} criteria - Filter function or object with key-value pairs * @returns {array} Array of matching models */ where(criteria) { if (typeof criteria === "function") { return this.models.filter(criteria); } if (typeof criteria === "object") { return this.models.filter((model) => { return Object.entries(criteria).every(([key, value]) => { return model.get(key) === value; }); }); } return []; } /** * Find first model matching criteria * @param {function|object} criteria - Filter function or object with key-value pairs * @returns {Model|undefined} First matching model or undefined */ findWhere(criteria) { const results = this.where(criteria); return results.length > 0 ? results[0] : void 0; } /** * Iterate over each model in the collection * @param {function} callback - Function to execute for each model (model, index, collection) * @param {object} thisArg - Optional value to use as this when executing callback * @returns {Collection} Returns the collection for chaining */ forEach(callback, thisArg) { if (typeof callback !== "function") { throw new TypeError("Callback must be a function"); } this.models.forEach((model, index) => { callback.call(thisArg, model, index, this); }); return this; } /** * Sort collection by comparator function * @param {function|string} comparator - Comparison function or attribute name * @param {object} options - Sort options */ sort(comparator, options = {}) { if (typeof comparator === "string") { const attr = comparator; comparator = (a, b) => { const aVal = a.get(attr); const bVal = b.get(attr); if (aVal < bVal) return -1; if (aVal > bVal) return 1; return 0; }; } this.models.sort(comparator); if (!options.silent) { this.trigger("sort", { collection: this }); } return this; } /** * Convert collection to JSON array * @returns {array} Array of model JSON representations */ toJSON() { return this.models.map((model) => model.toJSON()); } /** * Cancel any active fetch request * @returns {boolean} True if a request was cancelled, false if no active request */ cancel() { if (this.currentRequest && this.abortController) { console.info("Collection: Manually cancelling active request"); this.abortController.abort(); return true; } return false; } /** * Check if collection has an active fetch request * @returns {boolean} True if fetch is in progress */ isFetching() { return !!this.currentRequest; } /** * Build URL for collection endpoint * @returns {string} Collection API URL */ buildUrl() { return this.endpoint; } // EventEmitter API: on, off, once, emit (from mixin). /** * Iterator support for for...of loops */ *[Symbol.iterator]() { for (const model of this.models) { yield model; } } /** * Static method to create collection from array data * @param {function} ModelClass - Model class constructor * @param {array} data - Array of model data * @param {object} options - Collection options * @returns {Collection} New collection instance */ static fromArray(ModelClass, data = [], options = {}) { const collection = new this(ModelClass, options); collection.add(data, { silent: true }); return collection; } } Object.assign(Collection.prototype, EventEmitter); class ToastService { constructor(options = {}) { this.options = { containerId: "toast-container", position: "top-end", // top-start, top-center, top-end, middle-start, etc. autohide: true, defaultDelay: 5e3, // 5 seconds maxToasts: 5, // Maximum number of toasts to show at once ...options }; this.toasts = /* @__PURE__ */ new Map(); this.toastCounter = 0; this.init(); } /** * Initialize the toast service */ init() { this.createContainer(); } /** * Create the toast container if it doesn't exist */ createContainer() { let container = document.getElementById(this.options.containerId); if (!container) { container = document.createElement("div"); container.id = this.options.containerId; container.className = `toast-container position-fixed ${this.getPositionClasses()}`; container.style.zIndex = "1070"; container.setAttribute("aria-live", "polite"); container.setAttribute("aria-atomic", "true"); document.body.appendChild(container); } this.container = container; } /** * Get CSS classes for toast positioning */ getPositionClasses() { const positionMap = { "top-start": "top-0 start-0 p-3", "top-center": "top-0 start-50 translate-middle-x p-3", "top-end": "top-0 end-0 p-3", "middle-start": "top-50 start-0 translate-middle-y p-3", "middle-center": "top-50 start-50 translate-middle p-3", "middle-end": "top-50 end-0 translate-middle-y p-3", "bottom-start": "bottom-0 start-0 p-3", "bottom-center": "bottom-0 start-50 translate-middle-x p-3", "bottom-end": "bottom-0 end-0 p-3" }; return positionMap[this.options.position] || positionMap["top-end"]; } /** * Show a success toast * @param {string} message - The message to display * @param {object} options - Additional options */ success(message, options = {}) { return this.show(message, "success", { icon: "bi-check-circle-fill", ...options }); } /** * Show an error toast * @param {string} message - The message to display * @param {object} options - Additional options */ error(message, options = {}) { return this.show(message, "error", { icon: "bi-exclamation-triangle-fill", autohide: false, // Keep error toasts visible until manually dismissed ...options }); } /** * Show an info toast * @param {string} message - The message to display * @param {object} options - Additional options */ info(message, options = {}) { return this.show(message, "info", { icon: "bi-info-circle-fill", ...options }); } /** * Show a warning toast * @param {string} message - The message to display * @param {object} options - Additional options */ warning(message, options = {}) { return this.show(message, "warning", { icon: "bi-exclamation-triangle-fill", ...options }); } /** * Show a plain toast without specific styling * @param {string} message - The message to display * @param {object} options - Additional options */ plain(message, options = {}) { return this.show(message, "plain", { ...options }); } /** * Show a toast with specified type and options * @param {string} message - The message to display * @param {string} type - Toast type (success, error, info, warning) * @param {object} options - Additional options */ show(message, type = "info", options = {}) { this.enforceMaxToasts(); const toastId = `toast-${++this.toastCounter}`; const config = { title: this.getDefaultTitle(type), icon: this.getDefaultIcon(type), autohide: this.options.autohide, delay: this.options.defaultDelay, dismissible: true, ...options }; const toastElement = this.createToastElement(toastId, message, type, config); this.container.appendChild(toastElement); if (typeof bootstrap === "undefined") { throw new Error("Bootstrap is required for ToastService. Make sure Bootstrap 5 is loaded."); } const bsToast = new bootstrap.Toast(toastElement, { autohide: config.autohide, delay: config.delay }); this.toasts.set(toastId, { element: toastElement, bootstrap: bsToast, type, message }); toastElement.addEventListener("hidden.bs.toast", () => { this.cleanup(toastId); }); bsToast.show(); return { id: toastId, hide: () => { try { bsToast.hide(); } catch (error) { console.warn("Error hiding toast:", error); } }, dispose: () => this.cleanup(toastId), updateProgress: options.updateProgress || null }; } /** * Show a toast with a View component in the body * @param {View} view - The View component to display * @param {string} type - Toast type (success, error, info, warning, plain) * @param {object} options - Additional options */ showView(view, type = "info", options = {}) { this.enforceMaxToasts(); const toastId = `toast-${++this.toastCounter}`; const config = { title: options.title || this.getDefaultTitle(type), icon: options.icon || this.getDefaultIcon(type), autohide: this.options.autohide, delay: this.options.defaultDelay, dismissible: true, ...options }; const toastElement = this.createViewToastElement(toastId, view, type, config); this.container.appendChild(toastElement); if (typeof bootstrap === "undefined") { throw new Error("Bootstrap is required for ToastService. Make sure Bootstrap 5 is loaded."); } const bsToast = new bootstrap.Toast(toastElement, { autohide: config.autohide, delay: config.delay }); this.toasts.set(toastId, { element: toastElement, bootstrap: bsToast, type, view, message: "View toast" }); toastElement.addEventListener("hidden.bs.toast", () => { this.cleanupView(toastId); }); const bodyContainer = toastElement.querySelector(".toast-view-body"); if (bodyContainer && view) { view.render(true, bodyContainer); } bsToast.show(); return { id: toastId, view, hide: () => { try { bsToast.hide(); } catch (error) { console.warn("Error hiding view toast:", error); } }, dispose: () => this.cleanupView(toastId), updateProgress: (progressInfo) => { if (view && typeof view.updateProgress === "function") { view.updateProgress(progressInfo); } } }; } /** * Create toast DOM element */ createToastElement(id, message, type, config) { const toast = document.createElement("div"); toast.id = id; toast.className = `toast toast-service-${type}`; toast.setAttribute("role", "alert"); toast.setAttribute("aria-live", "assertive"); toast.setAttribute("aria-atomic", "true"); const header = config.title || config.icon ? this.createToastHeader(config, type) : ""; const body = this.createToastBody(message, config.icon && !config.title); toast.innerHTML = ` ${header} ${body} `; return toast; } /** * Create toast DOM element for View component */ createViewToastElement(id, view, type, config) { const toast = document.createElement("div"); toast.id = id; toast.className = `toast toast-service-${type}`; toast.setAttribute("role", "alert"); toast.setAttribute("aria-live", "assertive"); toast.setAttribute("aria-atomic", "true"); const header = config.title || config.icon ? this.createToastHeader(config, type) : ""; const body = this.createViewToastBody(); toast.innerHTML = ` ${header} ${body} `; return toast; } /** * Create toast body for View component */ createViewToastBody() { return ` <div class="toast-body p-0"> <div class="toast-view-body p-3"></div> </div> `; } /** * Create toast header with title and icon */ createToastHeader(config, _type) { const iconHtml = config.icon ? `<i class="${config.icon} toast-service-icon me-2"></i>` : ""; const titleHtml = config.title ? `<strong class="me-auto">${iconHtml}${this.escapeHtml(config.title)}</strong>` : ""; const timeHtml = config.showTime ? `<small class="text-muted">${this.getTimeString()}</small>` : ""; const closeButton = config.dismissible ? `<button type="button" class="btn-close toast-service-close" data-bs-dismiss="toast" aria-label="Close"></button>` : ""; if (!titleHtml && !timeHtml && !closeButton) { return ""; } return ` <div class="toast-header"> ${titleHtml} ${timeHtml} ${closeButton} </div> `; } /** * Create toast body with message */ createToastBody(message, showIcon = false) { const iconHtml = showIcon ? `<i class="${this.getDefaultIcon("info")} toast-service-icon me-2"></i>` : ""; return ` <div class="toast-body d-flex align-items-center"> ${iconHtml} <span>${this.escapeHtml(message)}</span> </div> `; } /** * Get default title for toast type */ getDefaultTitle(type) { const titles = { success: "Success", error: "Error", warning: "Warning", info: "Information", plain: "" }; return titles[type] || "Notification"; } /** * Get default icon for toast type */ getDefaultIcon(type) { const icons = { success: "bi-check-circle-fill", error: "bi-exclamation-triangle-fill", warning: "bi-exclamation-triangle-fill", info: "bi-info-circle-fill", plain: "" }; return icons[type] || "bi-info-circle-fill"; } /** * Enforce maximum number of toasts */ enforceMaxToasts() { const activeToasts = Array.from(this.toasts.values()); if (activeToasts.length >= this.options.maxToasts) { const oldestId = this.toasts.keys().next().value; const oldest = this.toasts.get(oldestId); if (oldest) { oldest.bootstrap.hide(); } } } /** * Clean up toast resources */ cleanup(toastId) { const toast = this.toasts.get(toastId); if (toast) { try { toast.bootstrap.dispose(); } catch (e) { console.warn("Error disposing toast:", e); } if (toast.element && toast.element.parentNode) { toast.element.parentNode.removeChild(toast.element); } this.toasts.delete(toastId); } } /** * Clean up view toast resources with proper view disposal */ cleanupView(toastId) { const toast = this.toasts.get(toastId); if (toast) { if (toast.view && typeof toast.view.dispose === "function") { try { toast.view.dispose(); } catch (e) { console.warn("Error disposing view in toast:", e); } } try { toast.bootstrap.dispose(); } catch (e) { console.warn("Error disposing toast:", e); } if (toast.element && toast.element.parentNode) { toast.element.parentNode.removeChild(toast.element); } this.toasts.delete(toastId); } } /** * Hide all active toasts */ hideAll() { this.toasts.forEach((toast, _id) => { toast.bootstrap.hide(); }); } /** * Clear all toasts immediately */ clearAll() { this.toasts.forEach((toast, id) => { this.cleanup(id); }); } /** * Get current time string */ getTimeString() { return (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } /** * Escape HTML to prevent XSS */ escapeHtml(str) { const div = document.createElement("div"); div.textContent = str; return div.innerHTML; } /** * Dispose of the entire toast service */ dispose() { this.clearAll(); if (this.container && this.container.parentNode) { this.container.parentNode.removeChild(this.container); } } /** * Get statistics about active toasts */ getStats() { const stats = { total: this.toasts.size, byType: {} }; this.toasts.forEach((toast) => { stats.byType[toast.type] = (stats.byType[toast.type] || 0) + 1; }); return stats; } /** * Set global options */ setOptions(newOptions) { this.options = { ...this.options, ...newOptions }; if (newOptions.position) { if (this.container) { this.container.className = `toast-container position-fixed ${this.getPositionClasses()}`; } } } } class Group extends Model { constructor(data = {}) { super(data, { endpoint: "/api/group" }); } } class GroupList extends Collection { constructor(options = {}) { super({ ModelClass: Group, endpoint: "/api/group", size: 10, ...options }); } } const GroupForms = { create: { title: "Create Group", fields: [ { name: "name", type: "text", label: "Group Name", required: true, placeholder: "Enter group name" }, { name: "kind", type: "select", label: "Group Kind", required: true, options: [ { value: "org", label: "Organization" }, { value: "team", label: "Team" }, { value: "department", label: "Department" }, { value: "merchant", label: "Merchant" }, { value: "iso", label: "ISO" }, { value: "group", label: "Group" } ] }, { type: "collection", name: "parent", label: "Parent Group", Collection: GroupList, // Collection class labelField: "name", // Field to display in dropdown valueField: "id", // Field to use as value maxItems: 10, // Max items to show in dropdown placeholder: "Search groups...", emptyFetch: false, debounceMs: 300 // Search debounce delay } ] }, edit: { title: "Edit Group", fields: [ { name: "name", type: "text", label: "Group Name", required: true, placeholder: "Enter group name" }, { name: "kind", type: "select", label: "Group Kind", required: true, options: [ { value: "org", label: "Organization" }, { value: "division", label: "Division" }, { value: "department", label: "Department" }, { value: "team", label: "Team" }, { value: "merchant", label: "Merchant" }, { value: "partner", label: "Partner" }, { value: "client", label: "Client" }, { value: "iso", label: "ISO" }, { value: "location", label: "Location" }, { value: "region", label: "Region" }, { value: "route", label: "Route" }, { value: "project", label: "Project" }, { value: "role", label: "Role" }, { value: "test", label: "Testing" } ] }, { type: "collection", name: "parent", label: "Parent Group", Collection: GroupList, // Collection class labelField: "name", // Field to display in dropdown valueField: "id", // Field to use as value maxItems: 10, // Max items to show in dropdown placeholder: "Search groups...", emptyFetch: false, debounceMs: 300 // Search debounce delay }, { name: "metadata.domain", type: "text", label: "Default Domain", placeholder: "Enter Domain" }, { name: "metadata.portal", type: "text", label: "Default Portal", placeholder: "Enter Portal URL" }, { name: "is_active", type: "switch", label: "Is Active", cols: 4 } ] }, detailed: { title: "Group Details", fields: [ // Profile Header { type: "header", text: "Profile Information", level: 4, class: "text-primary mb-3" }, // Avatar and Basic Info { type: "group", columns: { xs: 12, md: 4 }, fields: [ { type: "image", name: "avatar", size: "lg", imageSize: { width: 200, height: 200 }, placeholder: "Upload your avatar", help: "Square images work best", columns: 12 }, { name: "is_active", type: "switch", label: "Is Active", columns: 12 } ] }, // Profile Details { type: "group", columns: { xs: 12, md: 8 }, title: "Details", fields: [ { name: "name", type: "text", label: "Group Name", required: true, placeholder: "Enter group name", columns: 12 }, { name: "kind", type: "select", label: "Group Kind", required: true, columns: 12, options: [ { value: "org", label: "Organization" }, { value: "team", label: "Team" }, { value: "department", label: "Department" }, { value: "merchant", label: "Merchant" }, { value: "iso", label: "ISO" }, { value: "group", label: "Group" } ] }, { type: "collection", name: "parent", label: "Parent Group", Collection: GroupList, // Collection class labelField: "name", // Field to display in dropdown valueField: "id", // Field to use as value maxItems: 10, // Max items to show in dropdown placeholder: "Search groups...", emptyFetch: false, debounceMs: 300, // Search debounce delay columns: 12 } ] }, // Account Settings { type: "group", columns: 12, title: "Account Settings", class: "pt-3", fields: [ { type: "select", name: "metadata.timezone", label: "Timezone", columns: 6, options: [ { value: "America/New_York", text: "Eastern Time" }, { value: "America/Chicago", text: "Central Time" }, { value: "America/Denver", text: "Mountain Time" }, { value: "America/Los_Angeles", text: "Pacific Time" }, { value: "UTC", text: "UTC" } ] }, { type: "select", name: "metadata.language", label: "Language", columns: 6, options: [ { value: "en", text: "English" }, { value: "es", text: "Spanish" }, { value: "fr", text: "French" }, { value: "de", text: "German" } ] }, { type: "switch", name: "metadata.notify.email", label: "Email Notifications", columns: 4 }, { type: "switch", name: "metadata.profile_public", label: "Public Profile", columns: 4 } ] } ] } }; Group.EDIT_FORM = GroupForms.edit; Group.CREATE_FORM = GroupForms.create; class User extends Model { constructor(data = {}) { super(data, { endpoint: "/api/user" }); } hasPermission(permission) { if (Array.isArray(permission)) { return permission.some((p) => this.hasPermission(p)); } const isSysPermission = permission.startsWith("sys."); const permissionToCheck = isSysPermission ? permission.substring(4) : permission; if (this._hasPermission(permissionToCheck)) { return true; } if (!isSysPermission && this.member && this.member.hasPermission(permission)) { return true; } return false; } _hasPermission(permission) { const permissions = this.get("permissions"); if (!permissions) {