web-mojo
Version:
WEB-MOJO - A lightweight JavaScript framework for building data-driven web applications
1,656 lines (1,655 loc) • 57.9 kB
JavaScript
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) {