wacom
Version:
Module which has common services, pipes, directives and interfaces which can be used on all projects.
1,247 lines (1,228 loc) • 156 kB
JavaScript
import * as i0 from '@angular/core';
import { InjectionToken, Inject, Optional, Injectable, signal, inject, ChangeDetectorRef, output, ElementRef, DestroyRef, Directive, input, effect, Pipe, isSignal, ApplicationRef, EnvironmentInjector, createComponent, makeEnvironmentProviders, NgModule } from '@angular/core';
import * as i1 from '@angular/router';
import * as i2 from '@angular/platform-browser';
import { DomSanitizer } from '@angular/platform-browser';
import { take, firstValueFrom, Subject, skip, takeUntil, share, filter, map, Observable, merge, combineLatest, timeout, ReplaySubject, EMPTY } from 'rxjs';
import { toObservable } from '@angular/core/rxjs-interop';
import * as i1$1 from '@angular/common/http';
import { HttpHeaders, HttpErrorResponse, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { first, catchError } from 'rxjs/operators';
import * as i1$2 from '@angular/common';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
const CONFIG_TOKEN = new InjectionToken('config');
const DEFAULT_CONFIG = {
store: {
prefix: 'waStore',
},
meta: {
useTitleSuffix: false,
warnMissingGuard: false,
defaults: { links: {} },
},
socket: false,
http: {
url: '',
headers: {},
},
};
const DEFAULT_HTTP_CONFIG = {
headers: {},
url: '',
};
const DEFAULT_NETWORK_CONFIG = {
endpoints: [
'https://api.webart.work/status',
// Opaque but useful reachability fallbacks:
'https://www.google.com/generate_204',
'https://www.gstatic.com/generate_204',
'https://www.cloudflare.com/cdn-cgi/trace',
],
intervalMs: 30_000,
timeoutMs: 2_500,
goodLatencyMs: 300,
maxConsecutiveFails: 3,
};
const NETWORK_CONFIG = new InjectionToken('NETWORK_CONFIG', {
factory: () => DEFAULT_NETWORK_CONFIG,
});
const isDefined = (val) => typeof val !== 'undefined';
class MetaService {
constructor(config, router, meta, titleService) {
this.config = config;
this.router = router;
this.meta = meta;
this.titleService = titleService;
this.config = this.config || DEFAULT_CONFIG;
this._meta = this.config.meta || {};
this._warnMissingGuard();
}
/**
* Sets the default meta tags.
*
* @param defaults - The default meta tags.
*/
setDefaults(defaults) {
this._meta.defaults = {
...this._meta.defaults,
...defaults,
};
}
/**
* Sets the title and optional title suffix.
*
* @param title - The title to set.
* @param titleSuffix - The title suffix to append.
* @returns The MetaService instance.
*/
setTitle(title, titleSuffix) {
let titleContent = isDefined(title)
? title || ''
: this._meta.defaults?.['title'] || '';
if (this._meta.useTitleSuffix) {
titleContent += isDefined(titleSuffix)
? titleSuffix
: this._meta.defaults?.['titleSuffix'] || '';
}
this._updateMetaTag('title', titleContent);
this._updateMetaTag('og:title', titleContent);
this._updateMetaTag('twitter:title', titleContent);
this.titleService.setTitle(titleContent);
return this;
}
/**
* Sets link tags.
*
* @param links - The links to set.
* @returns The MetaService instance.
*/
setLink(links) {
Object.keys(links).forEach((rel) => {
let link = document.createElement('link');
link.setAttribute('rel', rel);
link.setAttribute('href', links[rel]);
document.head.appendChild(link);
});
return this;
}
/**
* Sets a meta tag.
*
* @param tag - The meta tag name.
* @param value - The meta tag value.
* @param prop - The meta tag property.
*/
setTag(tag, value, prop) {
if (tag === 'title' || tag === 'titleSuffix') {
throw new Error(`Attempt to set ${tag} through 'setTag': 'title' and 'titleSuffix' are reserved. Use 'MetaService.setTitle' instead.`);
}
const content = (isDefined(value)
? value || ''
: this._meta.defaults?.[tag] || '') + '';
this._updateMetaTag(tag, content, prop);
if (tag === 'description') {
this._updateMetaTag('og:description', content, prop);
this._updateMetaTag('twitter:description', content, prop);
}
}
/**
* Updates a meta tag.
*
* @param tag - The meta tag name.
* @param value - The meta tag value.
* @param prop - The meta tag property.
*/
_updateMetaTag(tag, value, prop) {
prop =
prop ||
(tag.startsWith('og:') || tag.startsWith('twitter:')
? 'property'
: 'name');
this.meta.updateTag({ [prop]: tag, content: value });
}
/**
* Removes a meta tag.
*
* @param tag - The meta tag name.
* @param prop - The meta tag property.
*/
removeTag(tag, prop) {
prop =
prop ||
(tag.startsWith('og:') || tag.startsWith('twitter:')
? 'property'
: 'name');
this.meta.removeTag(`${prop}="${tag}"`);
}
/**
* Warns about missing meta guards in routes.
*/
_warnMissingGuard() {
if (isDefined(this._meta.warnMissingGuard) &&
!this._meta.warnMissingGuard) {
return;
}
const hasDefaultMeta = !!Object.keys(this._meta.defaults ?? {}).length;
const hasMetaGuardInArr = (it) => it && it.IDENTIFIER === 'MetaGuard';
let hasShownWarnings = false;
const checkRoute = (route) => {
const hasRouteMeta = route.data && route.data['meta'];
const showWarning = !isDefined(route.redirectTo) &&
(hasDefaultMeta || hasRouteMeta) &&
!(route.canActivate || []).some(hasMetaGuardInArr);
if (showWarning) {
console.warn(`Route with path "${route.path}" has ${hasRouteMeta ? '' : 'default '}meta tags, but does not use MetaGuard. Please add MetaGuard to the canActivate array in your route configuration`);
hasShownWarnings = true;
}
(route.children || []).forEach(checkRoute);
};
this.router.config.forEach(checkRoute);
if (hasShownWarnings) {
console.warn(`To disable these warnings, set metaConfig.warnMissingGuard: false in your MetaConfig passed to MetaModule.forRoot()`);
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaService, deps: [{ token: CONFIG_TOKEN, optional: true }, { token: i1.Router }, { token: i2.Meta }, { token: i2.Title }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}], ctorParameters: () => [{ type: undefined, decorators: [{
type: Inject,
args: [CONFIG_TOKEN]
}, {
type: Optional
}] }, { type: i1.Router }, { type: i2.Meta }, { type: i2.Title }] });
class MetaGuard {
static { this.IDENTIFIER = 'MetaGuard'; }
constructor(metaService, config) {
this.metaService = metaService;
this.config = config;
if (!this.config)
this.config = DEFAULT_CONFIG;
this._meta = this.config.meta || {};
this._meta.defaults = this._meta.defaults || {};
}
canActivate(route, state) {
this._processRouteMetaTags(route.data && route.data['meta']);
return true;
}
_processRouteMetaTags(meta = {}) {
if (meta.disableUpdate) {
return;
}
if (meta.title) {
this.metaService.setTitle(meta.title, meta.titleSuffix);
}
if (meta.links && Object.keys(meta.links).length) {
this.metaService.setLink(meta.links);
}
if (this._meta.defaults?.links &&
Object.keys(this._meta.defaults.links).length) {
this.metaService.setLink(this._meta.defaults.links);
}
Object.keys(meta).forEach((prop) => {
if (prop === 'title' ||
prop === 'titleSuffix' ||
prop === 'links') {
return;
}
Object.keys(meta[prop]).forEach((key) => {
this.metaService.setTag(key, meta[prop][key], prop);
});
});
Object.keys(this._meta.defaults).forEach((key) => {
if (key in meta ||
key === 'title' ||
key === 'titleSuffix' ||
key === 'links') {
return;
}
this.metaService.setTag(key, this._meta.defaults[key]);
});
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaGuard, deps: [{ token: MetaService }, { token: CONFIG_TOKEN, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaGuard, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaGuard, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [{ type: MetaService }, { type: undefined, decorators: [{
type: Inject,
args: [CONFIG_TOKEN]
}, {
type: Optional
}] }] });
// Core utilities and helpers for the Wacom app
// Add capitalize method to String prototype if it doesn't already exist
if (!String.prototype.capitalize) {
String.prototype.capitalize = function () {
if (this.length > 0) {
return this.charAt(0).toUpperCase() + this.slice(1).toLowerCase();
}
return '';
};
}
class CoreService {
constructor() {
this.deviceID = localStorage.getItem('deviceID') ||
(typeof crypto?.randomUUID === 'function'
? crypto.randomUUID()
: this.UUID());
// After While
this._afterWhile = {};
// Device management
this.device = '';
// Version management
this.version = '1.0.0';
this.appVersion = '';
this.dateVersion = '';
// Locking management
this._locked = {};
this._unlockResolvers = {};
localStorage.setItem('deviceID', this.deviceID);
this.detectDevice();
}
/**
* Generates a UUID (Universally Unique Identifier) version 4.
*
* This implementation uses `Math.random()` to generate random values,
* making it suitable for general-purpose identifiers, but **not** for
* cryptographic or security-sensitive use cases.
*
* The format follows the UUID v4 standard: `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`
* where:
* - `x` is a random hexadecimal digit (0–f)
* - `4` indicates UUID version 4
* - `y` is one of 8, 9, A, or B
*
* Example: `f47ac10b-58cc-4372-a567-0e02b2c3d479`
*
* @returns A string containing a UUID v4.
*/
UUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Converts an object to an array. Optionally holds keys instead of values.
*
* @param {any} obj - The object to be converted.
* @param {boolean} [holder=false] - If true, the keys will be held in the array; otherwise, the values will be held.
* @returns {any[]} The resulting array.
*/
ota(obj, holder = false) {
if (Array.isArray(obj))
return obj;
if (typeof obj !== 'object' || obj === null)
return [];
const arr = [];
for (const each in obj) {
if (obj.hasOwnProperty(each) &&
(obj[each] ||
typeof obj[each] === 'number' ||
typeof obj[each] === 'boolean')) {
if (holder) {
arr.push(each);
}
else {
arr.push(obj[each]);
}
}
}
return arr;
}
/**
* Removes elements from `fromArray` that are present in `removeArray` based on a comparison field.
*
* @param {any[]} removeArray - The array of elements to remove.
* @param {any[]} fromArray - The array from which to remove elements.
* @param {string} [compareField='_id'] - The field to use for comparison.
* @returns {any[]} The modified `fromArray` with elements removed.
*/
splice(removeArray, fromArray, compareField = '_id') {
if (!Array.isArray(removeArray) || !Array.isArray(fromArray)) {
return fromArray;
}
const removeSet = new Set(removeArray.map((item) => item[compareField]));
return fromArray.filter((item) => !removeSet.has(item[compareField]));
}
/**
* Unites multiple _id values into a single unique _id.
* The resulting _id is unique regardless of the order of the input _id values.
*
* @param {...string[]} args - The _id values to be united.
* @returns {string} The unique combined _id.
*/
ids2id(...args) {
args.sort((a, b) => {
if (Number(a.toString().substring(0, 8)) >
Number(b.toString().substring(0, 8))) {
return 1;
}
return -1;
});
return args.join();
}
/**
* Delays the execution of a callback function for a specified amount of time.
* If called again within that time, the timer resets.
*
* @param {string | object | (() => void)} doc - A unique identifier for the timer, an object to host the timer, or the callback function.
* @param {() => void} [cb] - The callback function to execute after the delay.
* @param {number} [time=1000] - The delay time in milliseconds.
*/
afterWhile(doc, cb, time = 1000) {
if (typeof doc === 'function') {
cb = doc;
doc = 'common';
}
if (typeof cb === 'function' && typeof time === 'number') {
if (typeof doc === 'string') {
clearTimeout(this._afterWhile[doc]);
this._afterWhile[doc] = window.setTimeout(cb, time);
}
else if (typeof doc === 'object') {
clearTimeout(doc.__afterWhile);
doc.__afterWhile =
window.setTimeout(cb, time);
}
else {
console.warn('badly configured after while');
}
}
}
/**
* Recursively copies properties from one object to another.
* Handles nested objects, arrays, and Date instances appropriately.
*
* @param from - The source object from which properties are copied.
* @param to - The target object to which properties are copied.
*/
copy(from, to) {
for (const each in from) {
if (typeof from[each] !== 'object' ||
from[each] instanceof Date ||
Array.isArray(from[each]) ||
from[each] === null) {
to[each] = from[each];
}
else {
if (typeof to[each] !== 'object' ||
to[each] instanceof Date ||
Array.isArray(to[each]) ||
to[each] === null) {
to[each] = {};
}
this.copy(from[each], to[each]);
}
}
}
/**
* Detects the device type based on the user agent.
*/
detectDevice() {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
if (/windows phone/i.test(userAgent)) {
this.device = 'Windows Phone';
}
else if (/android/i.test(userAgent)) {
this.device = 'Android';
}
else if (/iPad|iPhone|iPod/.test(userAgent) &&
!window.MSStream) {
this.device = 'iOS';
}
else {
this.device = 'Web';
}
}
/**
* Checks if the device is a mobile device.
* @returns {boolean} - Returns true if the device is a mobile device.
*/
isMobile() {
return (this.device === 'Windows Phone' ||
this.device === 'Android' ||
this.device === 'iOS');
}
/**
* Checks if the device is a tablet.
* @returns {boolean} - Returns true if the device is a tablet.
*/
isTablet() {
return this.device === 'iOS' && /iPad/.test(navigator.userAgent);
}
/**
* Checks if the device is a web browser.
* @returns {boolean} - Returns true if the device is a web browser.
*/
isWeb() {
return this.device === 'Web';
}
/**
* Checks if the device is an Android device.
* @returns {boolean} - Returns true if the device is an Android device.
*/
isAndroid() {
return this.device === 'Android';
}
/**
* Checks if the device is an iOS device.
* @returns {boolean} - Returns true if the device is an iOS device.
*/
isIos() {
return this.device === 'iOS';
}
/**
* Sets the combined version string based on appVersion and dateVersion.
*/
setVersion() {
this.version = this.appVersion || '';
this.version += this.version && this.dateVersion ? ' ' : '';
this.version += this.dateVersion || '';
}
/**
* Sets the app version and updates the combined version string.
*
* @param {string} appVersion - The application version to set.
*/
setAppVersion(appVersion) {
this.appVersion = appVersion;
this.setVersion();
}
/**
* Sets the date version and updates the combined version string.
*
* @param {string} dateVersion - The date version to set.
*/
setDateVersion(dateVersion) {
this.dateVersion = dateVersion;
this.setVersion();
}
/**
* Locks a resource to prevent concurrent access.
* @param which - The resource to lock, identified by a string.
*/
lock(which) {
this._locked[which] = true;
if (!this._unlockResolvers[which]) {
this._unlockResolvers[which] = [];
}
}
/**
* Unlocks a resource, allowing access.
* @param which - The resource to unlock, identified by a string.
*/
unlock(which) {
this._locked[which] = false;
if (this._unlockResolvers[which]) {
this._unlockResolvers[which].forEach((resolve) => resolve());
this._unlockResolvers[which] = [];
}
}
/**
* Returns a Promise that resolves when the specified resource is unlocked.
* @param which - The resource to watch for unlocking, identified by a string.
* @returns A Promise that resolves when the resource is unlocked.
*/
onUnlock(which) {
if (!this._locked[which]) {
return Promise.resolve();
}
return new Promise((resolve) => {
if (!this._unlockResolvers[which]) {
this._unlockResolvers[which] = [];
}
this._unlockResolvers[which].push(resolve);
});
}
/**
* Checks if a resource is locked.
* @param which - The resource to check, identified by a string.
* @returns True if the resource is locked, false otherwise.
*/
locked(which) {
return !!this._locked[which];
}
// Angular Signals //
/**
* Converts a plain object into a signal-wrapped object.
* Optionally wraps specific fields of the object as individual signals,
* and merges them into the returned signal for fine-grained reactivity.
*
* @template Document - The type of the object being wrapped.
* @param {Document} document - The plain object to wrap into a signal.
* @param {Record<string, (doc: Document) => unknown>} [signalFields={}] -
* Optional map where each key is a field name and the value is a function
* to extract the initial value for that field. These fields will be wrapped
* as separate signals and embedded in the returned object.
*
* @returns {WritableSignal<Document>} A signal-wrapped object, possibly containing
* nested field signals for more granular control.
*
* @example
* const user = { _id: '1', name: 'Alice', score: 42 };
* const sig = toSignal(user, { score: (u) => u.score });
* console.log(sig().name); // 'Alice'
* console.log(sig().score()); // 42 — field is now a signal
*/
toSignal(document, signalFields = {}) {
if (Object.keys(signalFields).length) {
const fields = {};
for (const key in signalFields) {
fields[key] = signal(signalFields[key](document));
}
return signal({ ...document, ...fields });
}
else {
return signal(document);
}
}
/**
* Converts an array of objects into an array of Angular signals.
* Optionally wraps specific fields of each object as individual signals.
*
* @template Document - The type of each object in the array.
* @param {Document[]} arr - Array of plain objects to convert into signals.
* @param {Record<string, (doc: Document) => unknown>} [signalFields={}] -
* Optional map where keys are field names and values are functions that extract the initial value
* from the object. These fields will be turned into separate signals.
*
* @returns {WritableSignal<Document>[]} An array where each item is a signal-wrapped object,
* optionally with individual fields also wrapped in signals.
*
* @example
* toSignalsArray(users, {
* name: (u) => u.name,
* score: (u) => u.score,
* });
*/
toSignalsArray(arr, signalFields = {}) {
return arr.map((obj) => this.toSignal(obj, signalFields));
}
/**
* Adds a new object to the signals array.
* Optionally wraps specific fields of the object as individual signals before wrapping the whole object.
*
* @template Document - The type of the object being added.
* @param {WritableSignal<Document>[]} signals - The signals array to append to.
* @param {Document} item - The object to wrap and push as a signal.
* @param {Record<string, (doc: Document) => unknown>} [signalFields={}] -
* Optional map of fields to be wrapped as signals within the object.
*
* @returns {void}
*/
pushSignal(signals, item, signalFields = {}) {
signals.push(this.toSignal(item, signalFields));
}
/**
* Removes the first signal from the array whose object's field matches the provided value.
* @template Document
* @param {WritableSignal<Document>[]} signals - The signals array to modify.
* @param {unknown} value - The value to match.
* @param {string} [field='_id'] - The object field to match against.
* @returns {void}
*/
removeSignalByField(signals, value, field = '_id') {
const idx = signals.findIndex((sig) => sig()[field] === value);
if (idx > -1)
signals.splice(idx, 1);
}
/**
* Returns a generic trackBy function for *ngFor, tracking by the specified object field.
* @template Document
* @param {string} field - The object field to use for tracking (e.g., '_id').
* @returns {(index: number, sig: Signal<Document>) => unknown} TrackBy function for Angular.
*/
trackBySignalField(field) {
return (_, sig) => sig()[field];
}
/**
* Finds the first signal in the array whose object's field matches the provided value.
* @template Document
* @param {Signal<Document>[]} signals - Array of signals to search.
* @param {unknown} value - The value to match.
* @param {string} [field='_id'] - The object field to match against.
* @returns {Signal<Document> | undefined} The found signal or undefined if not found.
*/
findSignalByField(signals, value, field = '_id') {
return signals.find((sig) => sig()[field] === value);
}
/**
* Updates the first writable signal in the array whose object's field matches the provided value.
* @template Document
* @param {WritableSignal<Document>[]} signals - Array of writable signals to search.
* @param {unknown} value - The value to match.
* @param {(val: Document) => Document} updater - Function to produce the updated object.
* @param {string} field - The object field to match against.
* @returns {void}
*/
updateSignalByField(signals, value, updater, field) {
const sig = this.findSignalByField(signals, value, field);
if (sig)
sig.update(updater);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: CoreService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: CoreService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: CoreService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}], ctorParameters: () => [] });
/**
* Abstract reusable base class for CRUD list views.
* It encapsulates pagination, modals, and document handling logic.
*
* @template Service - A service implementing CrudServiceInterface for a specific document type
* @template Document - The data model extending CrudDocument
*/
class CrudComponent {
/**
* Constructor
*
* @param formConfig - Object describing form title and its component structure
* @param formService - Any service that conforms to FormServiceInterface (usually casted)
* @param crudService - CRUD service implementing get/create/update/delete
*/
constructor(formConfig, formService, crudService, module = '') {
/** The array of documents currently loaded and shown */
this.documents = signal([], ...(ngDevMode ? [{ debugName: "documents" }] : []));
/** Current pagination page */
this.page = 1;
/** CoreService handles timing and copying helpers */
this.__core = inject(CoreService);
/** ChangeDetectorRef handles on push strategy */
this.__cdr = inject(ChangeDetectorRef);
/** Fields considered when performing bulk updates. */
this.updatableFields = ['_id', 'name', 'description', 'data'];
/** Data source mode used for document retrieval. */
this.configType = 'server';
/** Number of documents fetched per page when paginating. */
this.perPage = 20;
/** Name of the collection or module used for contextual actions. */
this._module = '';
const form = formConfig;
this.__form = formService;
this.form = form;
this.crudService = crudService;
this._module = module;
}
/**
* Loads documents for a given page.
*/
setDocuments(page = this.page, query = '') {
return new Promise((resolve) => {
if (this.configType === 'server') {
this.page = page;
this.__core.afterWhile(this, () => {
this.crudService
.get({ page, query }, this.getOptions())
.subscribe((docs) => {
this.documents.update(() => docs.map((doc) => this.crudService.getSignal(doc)));
resolve();
this.__cdr.markForCheck();
});
}, 250);
}
else {
this.documents.update(() => this.crudService
.getDocs()
.map((doc) => this.crudService.getSignal(doc)));
this.crudService.loaded.pipe(take(1)).subscribe(() => {
resolve();
this.__cdr.markForCheck();
});
}
});
}
/**
* Clears temporary metadata before document creation.
*/
preCreate(doc) {
delete doc.__creating;
}
/**
* Funciton which controls whether the create functionality is available.
*/
allowCreate() {
return true;
}
/**
* Funciton which controls whether the update and delete functionality is available.
*/
allowMutate() {
return true;
}
/**
* Funciton which controls whether the unique url functionality is available.
*/
allowUrl() {
return true;
}
/** Determines whether manual sorting controls are available. */
allowSort() {
return false;
}
/**
* Funciton which prepare get crud options.
*/
getOptions() {
return {};
}
/**
* Handles bulk creation and updating of documents.
* In creation mode, adds new documents.
* In update mode, syncs changes and deletes removed entries.
*/
bulkManagement(isCreateFlow = true) {
return () => {
this.__form
.modalDocs(isCreateFlow
? []
: this.documents().map((obj) => Object.fromEntries(this.updatableFields.map((key) => [
key,
obj()[key],
]))))
.then(async (docs) => {
if (isCreateFlow) {
for (const doc of docs) {
this.preCreate(doc);
await firstValueFrom(this.crudService.create(doc));
}
}
else {
for (const document of this.documents()) {
if (!docs.find((d) => d._id === document()._id)) {
await firstValueFrom(this.crudService.delete(document()));
}
}
for (const doc of docs) {
const local = this.documents().find((document) => document()._id === doc._id);
if (local) {
local.update((document) => {
this.__core.copy(doc, document);
return document;
});
await firstValueFrom(this.crudService.update(local()));
}
else {
this.preCreate(doc);
await firstValueFrom(this.crudService.create(doc));
}
}
}
this.setDocuments();
});
};
}
/** Opens a modal to create a new document. */
create() {
this.__form.modal(this.form, {
label: 'Create',
click: async (created, close) => {
close();
this.preCreate(created);
await firstValueFrom(this.crudService.create(created));
this.setDocuments();
},
});
}
/** Displays a modal to edit an existing document. */
update(doc) {
this.__form.modal(this.form, {
label: 'Update',
click: (updated, close) => {
close();
this.__core.copy(updated, doc);
this.crudService.update(doc);
this.__cdr.markForCheck();
},
}, doc);
}
/** Requests confirmation before deleting the provided document. */
async delete(doc) {
this.crudService.delete(doc).subscribe(() => {
this.setDocuments();
});
}
/** Opens a modal to edit the document's unique URL. */
mutateUrl(doc) {
this.__form.modalUnique(this._module, 'url', doc);
}
/** Moves the given document one position up and updates ordering. */
moveUp(doc) {
const index = this.documents().findIndex((document) => document()._id === doc._id);
if (index) {
this.documents.update((documents) => {
documents.splice(index, 1);
documents.splice(index - 1, 0, this.crudService.getSignal(doc));
return documents;
});
}
for (let i = 0; i < this.documents().length; i++) {
if (this.documents()[i]().order !== i) {
this.documents()[i]().order = i;
this.crudService.update(this.documents()[i]());
}
}
this.__cdr.markForCheck();
}
/**
* Configuration object used by the UI for rendering table and handling actions.
*/
getConfig() {
const config = {
create: this.allowCreate()
? () => {
this.create();
}
: null,
update: this.allowMutate()
? (doc) => {
this.update(doc);
}
: null,
delete: this.allowMutate()
? (doc) => {
this.delete(doc);
}
: null,
buttons: [],
headerButtons: [],
allDocs: true,
};
if (this.allowUrl()) {
config.buttons.push({
icon: 'cloud_download',
click: (doc) => {
this.mutateUrl(doc);
},
});
}
if (this.allowSort()) {
config.buttons.push({
icon: 'arrow_upward',
click: (doc) => {
this.moveUp(doc);
},
});
}
if (this.allowCreate()) {
config.headerButtons.push({
icon: 'playlist_add',
click: this.bulkManagement(),
class: 'playlist',
});
}
if (this.allowMutate()) {
config.headerButtons.push({
icon: 'edit_note',
click: this.bulkManagement(false),
class: 'edit',
});
}
return this.configType === 'server'
? {
...config,
paginate: this.setDocuments.bind(this),
perPage: this.perPage,
setPerPage: this.crudService.setPerPage?.bind(this.crudService),
allDocs: false,
}
: config;
}
}
/**
* Stand-alone “click outside” directive (zoneless-safe).
*
* Usage:
* <div (clickOutside)="close()">…</div>
*/
class ClickOutsideDirective {
constructor() {
this.clickOutside = output();
this._host = inject((ElementRef));
this._cdr = inject(ChangeDetectorRef);
this._dref = inject(DestroyRef);
this.handler = (e) => {
if (!this._host.nativeElement.contains(e.target)) {
this.clickOutside.emit(e); // notify parent
this._cdr.markForCheck(); // trigger CD for OnPush comps
}
};
document.addEventListener('pointerdown', this.handler, true);
// cleanup
this._dref.onDestroy(() => document.removeEventListener('pointerdown', this.handler, true));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ClickOutsideDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.3", type: ClickOutsideDirective, isStandalone: true, selector: "[clickOutside]", outputs: { clickOutside: "clickOutside" }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ClickOutsideDirective, decorators: [{
type: Directive,
args: [{
selector: '[clickOutside]',
}]
}], ctorParameters: () => [], propDecorators: { clickOutside: [{ type: i0.Output, args: ["clickOutside"] }] } });
class ManualDisabledDirective {
constructor() {
this.el = inject(ElementRef);
// Bind as: [manualDisabled]="isDisabled"
this.manualDisabled = input(null, { ...(ngDevMode ? { debugName: "manualDisabled" } : {}), alias: 'manualDisabled' });
this.syncDisabledEffect = effect(() => {
const disabled = this.manualDisabled();
if (disabled == null)
return;
const native = this.el.nativeElement;
if (!native)
return;
native.disabled = !!disabled;
}, ...(ngDevMode ? [{ debugName: "syncDisabledEffect" }] : []));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualDisabledDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.3", type: ManualDisabledDirective, isStandalone: true, selector: "input[manualDisabled], textarea[manualDisabled]", inputs: { manualDisabled: { classPropertyName: "manualDisabled", publicName: "manualDisabled", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualDisabledDirective, decorators: [{
type: Directive,
args: [{
selector: 'input[manualDisabled], textarea[manualDisabled]',
}]
}], propDecorators: { manualDisabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "manualDisabled", required: false }] }] } });
class ManualNameDirective {
constructor() {
this.el = inject(ElementRef);
// Bind as: manualName="email" or [manualName]="expr"
this.manualName = input(null, { ...(ngDevMode ? { debugName: "manualName" } : {}), alias: 'manualName' });
this.syncNameEffect = effect(() => {
const name = this.manualName();
if (name == null)
return;
const native = this.el.nativeElement;
if (!native)
return;
if (native.name !== name) {
native.name = name;
}
}, ...(ngDevMode ? [{ debugName: "syncNameEffect" }] : []));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualNameDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.3", type: ManualNameDirective, isStandalone: true, selector: "input[manualName], textarea[manualName]", inputs: { manualName: { classPropertyName: "manualName", publicName: "manualName", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualNameDirective, decorators: [{
type: Directive,
args: [{
selector: 'input[manualName], textarea[manualName]',
}]
}], propDecorators: { manualName: [{ type: i0.Input, args: [{ isSignal: true, alias: "manualName", required: false }] }] } });
class ManualReadonlyDirective {
constructor() {
this.el = inject(ElementRef);
// Bind as: [manualReadonly]="true"
this.manualReadonly = input(null, { ...(ngDevMode ? { debugName: "manualReadonly" } : {}), alias: 'manualReadonly' });
this.syncReadonlyEffect = effect(() => {
const readonly = this.manualReadonly();
if (readonly == null)
return;
const native = this.el.nativeElement;
if (!native)
return;
native.readOnly = !!readonly;
}, ...(ngDevMode ? [{ debugName: "syncReadonlyEffect" }] : []));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualReadonlyDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.3", type: ManualReadonlyDirective, isStandalone: true, selector: "input[manualReadonly], textarea[manualReadonly]", inputs: { manualReadonly: { classPropertyName: "manualReadonly", publicName: "manualReadonly", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualReadonlyDirective, decorators: [{
type: Directive,
args: [{
selector: 'input[manualReadonly], textarea[manualReadonly]',
}]
}], propDecorators: { manualReadonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "manualReadonly", required: false }] }] } });
class ManualTypeDirective {
constructor() {
this.el = inject(ElementRef);
// Bind as: manualType="password" or [manualType]="expr"
this.manualType = input(null, { ...(ngDevMode ? { debugName: "manualType" } : {}), alias: 'manualType' });
this.syncTypeEffect = effect(() => {
const t = this.manualType();
if (!t)
return;
const native = this.el.nativeElement;
if (!native)
return;
if (native.type !== t) {
native.type = t;
}
}, ...(ngDevMode ? [{ debugName: "syncTypeEffect" }] : []));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualTypeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.3", type: ManualTypeDirective, isStandalone: true, selector: "input[manualType], textarea[manualType]", inputs: { manualType: { classPropertyName: "manualType", publicName: "manualType", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualTypeDirective, decorators: [{
type: Directive,
args: [{
selector: 'input[manualType], textarea[manualType]',
}]
}], propDecorators: { manualType: [{ type: i0.Input, args: [{ isSignal: true, alias: "manualType", required: false }] }] } });
class ArrPipe {
transform(data, type, refresh) {
if (!data) {
return [];
}
if (typeof data == 'string')
return data.split(type || ' ');
if (Array.isArray(data)) {
return data;
}
if (typeof data != 'object') {
return [];
}
let arr = [];
for (let each in data) {
if (!data[each])
continue;
if (type == 'prop') {
arr.push(each);
}
else if (type == 'value') {
arr.push(data[each]);
}
else {
arr.push({
prop: each,
value: data[each],
});
}
}
return arr;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ArrPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: ArrPipe, isStandalone: true, name: "arr" }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ArrPipe, decorators: [{
type: Pipe,
args: [{
name: 'arr',
}]
}] });
class MongodatePipe {
transform(_id) {
if (!_id)
return new Date();
let timestamp = _id.toString().substring(0, 8);
return new Date(parseInt(timestamp, 16) * 1000);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MongodatePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: MongodatePipe, isStandalone: true, name: "mongodate" }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MongodatePipe, decorators: [{
type: Pipe,
args: [{
name: 'mongodate',
}]
}] });
class NumberPipe {
transform(value) {
const result = Number(value); // Convert value to a number
return isNaN(result) ? 0 : result; // Return 0 if conversion fails
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NumberPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: NumberPipe, isStandalone: true, name: "number" }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NumberPipe, decorators: [{
type: Pipe,
args: [{
name: 'number',
}]
}] });
class PaginationPipe {
transform(arr, config, sort, search = '') {
if (!Array.isArray(arr))
return [];
arr = arr.slice();
for (let i = 0; i < arr.length; i++) {
arr[i].num = i + 1;
}
if (sort.direction) {
arr.sort((a, b) => {
if (a[sort.title] < b[sort.title]) {
return sort.direction == 'desc' ? 1 : -1;
}
if (a[sort.title] > b[sort.title]) {
return sort.direction == 'desc' ? -1 : 1;
}
return 0;
});
}
return arr.slice((config.page - 1) * config.perPage, config.page * config.perPage);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: PaginationPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: PaginationPipe, isStandalone: true, name: "page", pure: false }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: PaginationPipe, decorators: [{
type: Pipe,
args: [{
name: 'page',
pure: false,
}]
}] });
class SafePipe {
constructor() {
this._sanitizer = inject(DomSanitizer);
}
transform(html) {
return this._sanitizer.bypassSecurityTrustResourceUrl(html);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: SafePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: SafePipe, isStandalone: true, name: "safe" }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: SafePipe, decorators: [{
type: Pipe,
args: [{
name: 'safe',
}]
}] });
class SearchPipe {
transform(items, query, fields, limit, ignore = false, _reload) {
/* unwrap signals */
const q = isSignal(query) ? query() : query;
let f = isSignal(fields) ? fields() : fields;
/* allow “fields” to be a number (=limit) */
if (typeof f === 'number') {
limit = f;
f = undefined;
}
const docs = Array.isArray(items) ? items : Object.values(items);
if (ignore || !q)
return limit ? docs.slice(0, limit) : docs;
/* normalise fields */
const paths = !f
? ['name']
: Array.isArray(f)
? f
: f.trim().split(/\s+/);
/* normalise query */
const needles = Array.isArray(q)
? q.map((s) => s.toLowerCase())
: typeof q === 'object'
? Object.keys(q)
.filter((k) => q[k])
.map((k) => k.toLowerCase())
: [q.toLowerCase()];
const txtMatches = (val) => {
if (val == null)
return false;
const hay = val.toString().toLowerCase();
return needles.some((n) => hay.includes(n) || n.includes(hay));
};
const walk = (obj, parts) => {
if (!obj)
return false;