UNPKG

ngx-primeng-toolkit

Version:

A comprehensive TypeScript utility library for Angular component state management, PrimeNG table state management, ng-select helpers, data storage, and memoized HTTP caching. Compatible with Angular 19+ and PrimeNG 19+ (optimized for Angular 20+ and Prime

1,784 lines (1,773 loc) 79 kB
// src/dynamic-table-state-helper.ts import { HttpContext } from "@angular/common/http"; import { signal } from "@angular/core"; import { signalState, patchState } from "@ngrx/signals"; import { firstValueFrom } from "rxjs"; // src/http-context-tokens.ts import { HttpContextToken } from "@angular/common/http"; var SkipLoadingSpinner = new HttpContextToken(() => false); // src/types.ts import { z } from "zod"; var ManipulationType = /* @__PURE__ */ ((ManipulationType2) => { ManipulationType2["Create"] = "create"; ManipulationType2["Update"] = "update"; ManipulationType2["CreateChild"] = "create-child"; ManipulationType2["Delete"] = "delete"; ManipulationType2["View"] = "view"; ManipulationType2["Save"] = "save"; return ManipulationType2; })(ManipulationType || {}); var dynamicQueryResponseZodSchema = z.object({ data: z.any().array(), last_page: z.number(), last_row: z.number() }); var PagedDataResponseZodSchema = z.object({ payload: z.any().array(), totalCount: z.number() }); var NumberStringKeyDataSchema = z.object({ key: z.union([z.number(), z.string()]), data: z.string() }); var NullableApiResponseSchema = (payloadSchema) => z.object({ payload: payloadSchema.nullable() }); function createKeyData(key, data) { return { key, data }; } function isApiResponse(response) { return typeof response === "object" && response !== null && "data" in response && "status" in response && "success" in response; } function isPaginatedResponse(response) { return typeof response === "object" && response !== null && "data" in response && Array.isArray(response.data) && "meta" in response && typeof response.meta === "object"; } function isSimplePagedResponse(response) { return typeof response === "object" && response !== null && "payload" in response && Array.isArray(response.payload) && "totalCount" in response && typeof response.totalCount === "number"; } function isDynamicQueryResponse(response) { return typeof response === "object" && response !== null && "data" in response && Array.isArray(response.data) && "last_page" in response && "last_row" in response; } // src/utils.ts function cleanNullishFromObject(obj) { if (obj === void 0) { return {}; } return Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null)); } function hasNullishInObject(obj) { return Object.values(obj).some((val) => val === null || val === void 0); } function routeParamConcat(baseUrl, routeParam) { if (routeParam === void 0 || routeParam === null) { throw new Error("routeParam cannot be null or undefined"); } if (baseUrl.endsWith("/")) { return baseUrl.concat(routeParam.toString()); } return baseUrl.concat(`/${routeParam.toString()}`); } function binarySearch(arr, val) { if (arr.length === 0) { return -1; } let left = 0; let right = arr.length - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); if (arr[mid] === val) { return mid; } else if (arr[mid] < val) { left = mid + 1; } else { right = mid - 1; } } return -1; } function emptyCallback() { } function nullableKeyData(key, data) { if (key && data) { return { key, data }; } return null; } var ReloadNotification = class _ReloadNotification { static create() { return new _ReloadNotification(); } }; function createHierarchicalTree(data, idKey, parentIdKey, expanded = false) { if (!Array.isArray(data)) { throw new Error("data must be an array"); } const categoryMap = new Map( data.map((item) => { if (!Object.hasOwn(item, idKey) || !Object.hasOwn(item, parentIdKey)) { throw new Error("idKey or parentIdKey is missing", { cause: item }); } return [ item[idKey], { data: item, children: [], expanded } ]; }) ); const rootNodes = []; data.forEach((item) => { const parentId = item[parentIdKey]; if (parentId != null && categoryMap.has(parentId)) { const parent = categoryMap.get(parentId); parent.children.push(categoryMap.get(item[idKey])); } else { rootNodes.push(categoryMap.get(item[idKey])); } }); return rootNodes; } // src/dynamic-table-state-helper.ts function initialDynamicState() { return { data: [], isLoading: false, totalRecords: 0, size: 15, page: 1, filter: [], sort: [] }; } function calculatePrimengTablePagination(event, defaultValue = { page: 1, limit: 15 }) { if (!event) { return defaultValue; } const page = event.first && event.rows ? Math.floor(event.first / event.rows) : 0; const limit = event.rows ?? 15; return { page: page + 1, limit }; } var PrimeNgDynamicTableStateHelper = class _PrimeNgDynamicTableStateHelper { constructor(url, httpClient, skipLoadingSpinner = true) { this.url = url; this.httpClient = httpClient; this.urlWithOutRouteParam = url; this.skipLoadingSpinner = skipLoadingSpinner; } state = signalState( initialDynamicState() ); urlWithOutRouteParam; skipLoadingSpinner; #uniqueKey = signal("id"); uniqueKey = this.#uniqueKey.asReadonly(); #queryParams = {}; // Public readonly signals totalRecords = this.state.totalRecords; isLoading = this.state.isLoading; data = this.state.data; /** * Creates a new instance of PrimeNgDynamicTableStateHelper * @param options - Configuration options * @returns New instance of PrimeNgDynamicTableStateHelper */ static create(options) { return new _PrimeNgDynamicTableStateHelper( options.url, options.httpClient, options.skipLoadingSpinner ?? true ); } /** * Sets whether to skip the loading spinner * @param skip - Whether to skip the loading spinner * @returns This instance for method chaining */ setSkipLoadingSpinner(skip) { this.skipLoadingSpinner = skip; return this; } /** * Sets the unique key field for table rows * @param newUniqueKey - The field name to use as unique identifier * @returns This instance for method chaining */ setUniqueKey(newUniqueKey) { this.#uniqueKey.set(newUniqueKey); return this; } /** * Updates the API URL * @param newUrl - The new API URL * @returns This instance for method chaining */ setUrl(newUrl) { this.url = newUrl; this.urlWithOutRouteParam = newUrl; return this; } /** * Appends a route parameter to the URL * @param newRouteParam - The route parameter to append * @returns This instance for method chaining */ setRouteParam(newRouteParam) { this.url = routeParamConcat(this.urlWithOutRouteParam, newRouteParam); return this; } /** * Patches existing query parameters * @param value - Query parameters to merge * @returns This instance for method chaining */ patchQueryParams(value) { this.#queryParams = { ...this.#queryParams, ...value }; return this; } /** * Removes a specific query parameter * @param key - The key to remove * @returns This instance for method chaining */ removeQueryParam(key) { delete this.#queryParams[key]; return this; } /** * Sets all query parameters (replaces existing) * @param newQueryParams - New query parameters * @returns This instance for method chaining */ setQueryParams(newQueryParams) { this.#queryParams = newQueryParams; return this; } /** * Handles PrimeNG table lazy load events * @param event - The lazy load event from PrimeNG table */ async onLazyLoad(event) { if (this.isLoading()) { return; } patchState(this.state, { size: event.rows || 15, page: Math.floor((event.first || 0) / (event.rows || 15)) + 1, filter: this.filterMapper(event.filters || {}), sort: Object.keys(event.multiSortMeta || {}).length > 0 ? (event.multiSortMeta || []).map((sort) => ({ field: sort.field, dir: sort.order === 1 ? "asc" : "desc" })) : event.sortField ? [ { field: event.sortField, dir: (event.sortOrder || 1) === 1 ? "asc" : "desc" } ] : [] }); await this.fetchData(this.dtoBuilder()); } /** * Clears table data and resets to first page * @param table - Optional PrimeNG Table reference to reset */ async clearTableData(table) { if (this.isLoading()) { return; } patchState(this.state, { data: [], totalRecords: 0, page: 1, filter: [], sort: [] }); if (table) { table.reset(); } await this.fetchData(this.dtoBuilder()); } /** * Manually triggers data refresh with current state */ async refresh() { if (this.isLoading()) { return; } await this.fetchData(this.dtoBuilder()); } /** * Fetches data from the API */ async fetchData(dto) { if (this.isLoading()) { return; } try { patchState(this.state, { isLoading: true }); const params = new URLSearchParams(); Object.entries(this.#queryParams).forEach(([key, value]) => { params.append(key, String(value)); }); const urlWithParams = params.toString() ? `${this.url}?${params.toString()}` : this.url; const response = await firstValueFrom( this.httpClient.post(urlWithParams, dto, { context: new HttpContext().set( SkipLoadingSpinner, this.skipLoadingSpinner ) }) ); const validatedResponse = dynamicQueryResponseZodSchema.parse(response); patchState(this.state, { data: validatedResponse.data, totalRecords: validatedResponse.last_row, isLoading: false }); } catch (error) { patchState(this.state, { data: [], totalRecords: 0, isLoading: false }); throw error; } } /** * Builds the DTO for API requests */ dtoBuilder() { return { size: this.state.size(), page: this.state.page(), filter: this.state.filter(), sort: this.state.sort() }; } /** * Maps PrimeNG filters to API filter format */ filterMapper(dto) { const filters = []; Object.entries(dto).forEach(([field, filterData]) => { if (!filterData) return; const processFilter = (filter3) => { if (filter3.value === null || filter3.value === void 0 || filter3.value === "") return; const mappedType = this.evaluateInput(filter3.matchMode || "contains"); if (mappedType) { filters.push({ field, value: String(filter3.value), type: mappedType }); } }; if (Array.isArray(filterData)) { filterData.forEach(processFilter); } else { processFilter(filterData); } }); return filters; } /** * Maps PrimeNG filter match modes to API filter types */ evaluateInput(input) { const filterMap = { startsWith: "starts", notStartsWith: "!starts", endsWith: "ends", notEndsWith: "!ends", contains: "like", notContains: "!like", equals: "=", notEquals: "!=", greaterThan: ">", lessThan: "<", greaterThanOrEqual: ">=", lessThanOrEqual: "<=" }; return filterMap[input] || null; } }; // src/paged-table-state-helper.ts import { HttpContext as HttpContext2 } from "@angular/common/http"; import { signal as signal2 } from "@angular/core"; import { patchState as patchState2, signalState as signalState2 } from "@ngrx/signals"; import { firstValueFrom as firstValueFrom2 } from "rxjs"; function initialPagedState() { return { data: [], isLoading: false, totalRecords: 0, limit: 15, page: 1 }; } var PrimeNgPagedDataTableStateHelper = class _PrimeNgPagedDataTableStateHelper { constructor(url, httpClient, skipLoadingSpinner = true) { this.url = url; this.httpClient = httpClient; this.urlWithOutRouteParam = url; this.skipLoadingSpinner = skipLoadingSpinner; } #state = signalState2(initialPagedState()); urlWithOutRouteParam; skipLoadingSpinner; #uniqueKey = signal2("id"); uniqueKey = this.#uniqueKey.asReadonly(); #queryParams = {}; // Public readonly signals totalRecords = this.#state.totalRecords; isLoading = this.#state.isLoading; data = this.#state.data; currentPage = this.#state.page; currentPageSize = this.#state.limit; /** * Creates a new instance of PrimengPagedDataTableStateHelper * @param option - Configuration options * @returns New instance of PrimengPagedDataTableStateHelper */ static create(option) { return new _PrimeNgPagedDataTableStateHelper( option.url, option.httpClient, option.skipLoadingSpinner ?? true ); } /** * Creates a new instance without initial URL (can be set later) * @param option - Configuration options without URL * @returns New instance of PrimengPagedDataTableStateHelper */ static createWithBlankUrl(option) { return new _PrimeNgPagedDataTableStateHelper( "", option.httpClient, option.skipLoadingSpinner ?? true ); } /** * Sets whether to skip the loading spinner * @param skip - Whether to skip the loading spinner * @returns This instance for method chaining */ setSkipLoadingSpinner(skip) { this.skipLoadingSpinner = skip; return this; } /** * Sets the unique key field for table rows * @param newUniqueKey - The field name to use as unique identifier * @returns This instance for method chaining */ setUniqueKey(newUniqueKey) { this.#uniqueKey.set(newUniqueKey); return this; } /** * Updates the API URL * @param newUrl - The new API URL * @returns This instance for method chaining */ setUrl(newUrl) { this.url = newUrl; this.urlWithOutRouteParam = newUrl; return this; } /** * Appends a route parameter to the URL * @param newRouteParam - The route parameter to append * @returns This instance for method chaining */ setRouteParam(newRouteParam) { this.url = routeParamConcat(this.urlWithOutRouteParam, newRouteParam); return this; } /** * Patches existing query parameters * @param value - Query parameters to merge * @returns This instance for method chaining */ patchQueryParams(value) { this.#queryParams = { ...this.#queryParams, ...value }; return this; } /** * Removes a specific query parameter * @param key - The key to remove * @returns This instance for method chaining */ removeQueryParam(key) { delete this.#queryParams[key]; return this; } /** * Removes all query parameters * @returns This instance for method chaining */ removeAllQueryParams() { this.#queryParams = {}; return this; } /** * Sets all query parameters (replaces existing) * @param newQueryParams - New query parameters * @returns This instance for method chaining */ setQueryParams(newQueryParams) { this.#queryParams = newQueryParams; return this; } /** * Handles PrimeNG table lazy load events * @param event - The lazy load event from PrimeNG table */ async onLazyLoad(event) { if (this.isLoading()) { return; } const newPage = Math.floor((event.first || 0) / (event.rows || 15)) + 1; const newLimit = event.rows || 15; patchState2(this.#state, { limit: newLimit, page: newPage }); await this.fetchData(this.dtoBuilder()); } /** * Clears table data and resets to first page * @param table - Optional PrimeNG Table reference to reset */ async clearTableData(table) { if (this.isLoading()) { return; } patchState2(this.#state, { data: [], totalRecords: 0, page: 1 }); if (table) { table.reset(); } await this.fetchData(this.dtoBuilder()); } /** * Manually triggers data refresh with current state */ async refresh() { if (this.isLoading()) { return; } await this.fetchData(this.dtoBuilder()); } /** * Fetches data from the API */ async fetchData(dto) { if (this.isLoading()) { return; } try { patchState2(this.#state, { isLoading: true }); const params = new URLSearchParams(); Object.entries(this.#queryParams).forEach(([key, value]) => { params.append(key, String(value)); }); Object.entries(dto).forEach(([key, value]) => { params.append(key, String(value)); }); const urlWithParams = params.toString() ? `${this.url}?${params.toString()}` : this.url; const response = await firstValueFrom2( this.httpClient.get(urlWithParams, { context: new HttpContext2().set(SkipLoadingSpinner, this.skipLoadingSpinner) }) ); const validatedResponse = PagedDataResponseZodSchema.parse(response); patchState2(this.#state, { data: validatedResponse.payload, totalRecords: validatedResponse.totalCount, isLoading: false }); } catch (error) { patchState2(this.#state, { data: [], totalRecords: 0, isLoading: false }); throw error; } } /** * Builds the DTO for API requests */ dtoBuilder() { return { limit: this.#state.limit(), page: this.#state.page() }; } }; // src/table-utils.ts function createPrimengNumberMatchModes(styleClass = "p-text-capitalize", disabled = false) { return [ { label: "Equals", value: "equals", title: "Equals", styleClass, disabled }, { label: "Not Equals", value: "notEquals", title: "Not Equals", styleClass, disabled }, { label: "Greater Than", value: "greaterThan", title: "Greater Than", styleClass, disabled }, { label: "Greater Than Or Equals", value: "greaterThanOrEqual", title: "Greater Than Or Equals", styleClass, disabled }, { label: "Less Than", value: "lessThan", title: "Less Than", styleClass, disabled }, { label: "Less Than Or Equals", value: "lessThanOrEqual", title: "Less Than Or Equals", styleClass, disabled } ]; } function createPrimengStringMatchModes(styleClass = "p-text-capitalize", disabled = false) { return [ { label: "Contains", value: "contains", title: "Contains", styleClass, disabled }, { label: "Not Contains", value: "notContains", title: "Not Contains", styleClass, disabled }, { label: "Starts With", value: "startsWith", title: "Starts With", styleClass, disabled }, { label: "Not Starts With", value: "notStartsWith", title: "Not Starts With", styleClass, disabled }, { label: "Ends With", value: "endsWith", title: "Ends With", styleClass, disabled }, { label: "Not Ends With", value: "notEndsWith", title: "Not Ends With", styleClass, disabled } ]; } function createTextColumn(field, label, options = {}) { const header = { identifier: { label, field, isNested: options.isNested, hasSort: options.hasSort ?? false, styleClass: options.styleClass } }; if (options.hasFilter ?? false) { header.filter = { type: "text", placeholder: options.placeholder ?? `Search by ${label.toLowerCase()}`, matchModeOptions: options.matchModeOptions ?? createPrimengStringMatchModes(), defaultMatchMode: options.defaultMatchMode ?? "contains", ariaLabel: `Filter by ${label}`, styleClass: options.filterStyleClass }; } return header; } function createNumericColumn(field, label, options = {}) { const header = { identifier: { label, field, isNested: options.isNested, hasSort: options.hasSort ?? false, styleClass: options.styleClass } }; if (options.hasFilter ?? false) { header.filter = { type: "numeric", placeholder: options.placeholder ?? `Filter by ${label.toLowerCase()}`, matchModeOptions: options.matchModeOptions ?? createPrimengNumberMatchModes(), defaultMatchMode: options.defaultMatchMode ?? "equals", ariaLabel: `Filter by ${label}`, styleClass: options.filterStyleClass }; } return header; } function createBooleanColumn(field, label, options = {}) { const header = { identifier: { label, field, isNested: options.isNested, hasSort: options.hasSort ?? false, isBoolean: true, styleClass: options.styleClass } }; if (options.hasFilter ?? false) { header.filter = { type: "boolean", defaultMatchMode: "equals", ariaLabel: `Filter by ${label}`, styleClass: options.filterStyleClass }; } return header; } function createDateColumn(field, label, options = {}) { const header = { identifier: { label, field, isNested: options.isNested, hasSort: options.hasSort ?? false, isDate: true, styleClass: options.styleClass } }; if (options.hasFilter ?? false) { header.filter = { type: "date", placeholder: options.placeholder ?? `Select ${label.toLowerCase()}`, defaultMatchMode: "equals", ariaLabel: `Filter by ${label}`, styleClass: options.filterStyleClass }; } return header; } function createDateTimeColumn(field, label, options = {}) { const header = { identifier: { label, field, isNested: options.isNested, hasSort: options.hasSort ?? false, isDateTime: true, styleClass: options.styleClass } }; if (options.hasFilter ?? false) { header.filter = { type: "date", placeholder: options.placeholder ?? `Select ${label.toLowerCase()}`, defaultMatchMode: "equals", ariaLabel: `Filter by ${label}`, styleClass: options.filterStyleClass }; } return header; } function createTimeColumn(field, label, options = {}) { const header = { identifier: { label, field, isNested: options.isNested, hasSort: options.hasSort ?? false, isTimeOnly: true, styleClass: options.styleClass } }; if (options.hasFilter ?? false) { header.filter = { type: "text", placeholder: options.placeholder ?? `Filter by ${label.toLowerCase()}`, defaultMatchMode: "contains", ariaLabel: `Filter by ${label}`, styleClass: options.filterStyleClass }; } return header; } function createDropdownColumn(field, label, dropdownOptions, options = {}) { const header = { identifier: { label, field, hasSort: options.hasSort ?? false, styleClass: options.styleClass } }; if (options.hasFilter ?? false) { header.filter = { type: "dropdown", placeholder: options.placeholder ?? `Select ${label.toLowerCase()}`, matchModeOptions: dropdownOptions, defaultMatchMode: "equals", ariaLabel: `Filter by ${label}`, styleClass: options.filterStyleClass }; } return header; } function createMultiselectColumn(field, label, multiselectOptions, options = {}) { const header = { identifier: { label, field, hasSort: options.hasSort ?? false, styleClass: options.styleClass } }; if (options.hasFilter ?? false) { header.filter = { type: "multiselect", placeholder: options.placeholder ?? `Select ${label.toLowerCase()}`, matchModeOptions: multiselectOptions, defaultMatchMode: "equals", ariaLabel: `Filter by ${label}`, styleClass: options.filterStyleClass }; } return header; } function createSimpleColumn(field, label, options = {}) { return { identifier: { label, field, isNested: options.isNested, hasSort: options.hasSort ?? false, styleClass: options.styleClass } }; } function mergeTableHeaders(...headers) { return headers; } function createBooleanSelectItems(trueLabel = "Yes", falseLabel = "No") { return [ { label: trueLabel, value: true }, { label: falseLabel, value: false } ]; } function createStatusSelectItems(statusOptions) { return Object.entries(statusOptions).map(([value, label]) => ({ label, value: isNaN(Number(value)) ? value : Number(value) })); } // src/memoized-data-storage.ts import { signal as signal3 } from "@angular/core"; import { HttpContext as HttpContext3 } from "@angular/common/http"; import { firstValueFrom as firstValueFrom3 } from "rxjs"; var MemoizedDataStorage = class { /** * Creates a new instance of MemoizedDataStorage * @param httpClient Angular HttpClient instance for making HTTP requests * @param skipLoadingSpinner Whether to skip the loading spinner for HTTP requests */ constructor(httpClient, skipLoadingSpinner = true) { this.httpClient = httpClient; this.skipLoadingSpinner = skipLoadingSpinner; } skipLoadingSpinner = true; /** * Sets whether to skip the loading spinner for HTTP requests * @param skip Whether to skip the loading spinner * @returns This instance for method chaining */ setSkipLoadingSpinner(skip) { this.skipLoadingSpinner = skip; return this; } #singleData = signal3(null); #multipleData = signal3([]); #isLoading = signal3(false); // Public readonly signals for external consumption /** * Read-only signal containing single data object or null */ singleData = this.#singleData.asReadonly(); /** * Read-only signal containing array of data objects */ multipleData = this.#multipleData.asReadonly(); /** * Read-only signal indicating whether a request is currently loading */ isLoading = this.#isLoading.asReadonly(); // Private flag to control memoization behavior #isMemoizationDisabledOnNextRead = false; /** * Disables memoization for the next read operation and clears cached data * This forces the next loadSingleData or loadMultipleData call to fetch fresh data * * @example * ```typescript * const storage = new MemoizedDataStorage<User>(httpClient); * await storage.loadSingleData('/api/user/1'); // Fetches data * await storage.loadSingleData('/api/user/1'); // Returns cached data * * storage.disableMemoizationOnNextRead(); * await storage.loadSingleData('/api/user/1'); // Fetches fresh data * ``` */ disableMemoizationOnNextRead() { this.#isMemoizationDisabledOnNextRead = true; this.#singleData.set(null); this.#multipleData.set([]); } /** * Loads a single data object from the specified URL with optional query parameters * Uses memoization to avoid redundant requests unless explicitly disabled * * @param url The URL to fetch data from * @param queryParams Optional query parameters to include in the request * @returns Promise that resolves when the data is loaded * @throws Error if the HTTP request fails * * @example * ```typescript * const storage = new MemoizedDataStorage<User>(httpClient); * await storage.loadSingleData('/api/user/1', { include: 'profile' }); * const user = storage.singleData(); // User data or null * ``` */ async loadSingleData(url, queryParams = {}) { if (!this.#isMemoizationDisabledOnNextRead && this.#singleData() !== null) { return; } try { this.#isLoading.set(true); const data = await firstValueFrom3( this.httpClient.get(url, { params: queryParams, context: new HttpContext3().set(SkipLoadingSpinner, this.skipLoadingSpinner) }) ); this.#singleData.set(data); } catch (error) { this.#singleData.set(null); throw error; } finally { this.#isLoading.set(false); this.#isMemoizationDisabledOnNextRead = false; } } /** * Loads multiple data objects from the specified URL with optional query parameters * Uses memoization to avoid redundant requests unless explicitly disabled * * @param url The URL to fetch data from * @param queryParams Optional query parameters to include in the request * @returns Promise that resolves when the data is loaded * @throws Error if the HTTP request fails * * @example * ```typescript * const storage = new MemoizedDataStorage<User>(httpClient); * await storage.loadMultipleData('/api/users', { page: 1, limit: 10 }); * const users = storage.multipleData(); // Array of User data * ``` */ async loadMultipleData(url, queryParams = {}) { if (!this.#isMemoizationDisabledOnNextRead && this.#multipleData().length !== 0) { return; } try { this.#isLoading.set(true); const context = new HttpContext3(); if (this.skipLoadingSpinner) { context.set(SkipLoadingSpinner, true); } const data = await firstValueFrom3( this.httpClient.get(url, { params: queryParams, context }) ); this.#multipleData.set(Array.isArray(data) ? data : []); } catch (error) { this.#multipleData.set([]); throw error; } finally { this.#isLoading.set(false); this.#isMemoizationDisabledOnNextRead = false; } } /** * Clears all cached data and resets the storage to initial state * * @example * ```typescript * const storage = new MemoizedDataStorage<User>(httpClient); * await storage.loadSingleData('/api/user/1'); * storage.clear(); // Clears cached data * console.log(storage.singleData()); // null * console.log(storage.multipleData()); // [] * ``` */ clear() { this.#singleData.set(null); this.#multipleData.set([]); this.#isMemoizationDisabledOnNextRead = false; } /** * Checks if single data is currently cached * @returns true if single data is cached, false otherwise */ hasSingleData() { return this.#singleData() !== null; } /** * Checks if multiple data is currently cached * @returns true if multiple data is cached (non-empty array), false otherwise */ hasMultipleData() { return this.#multipleData().length > 0; } }; // src/component-state.ts import { computed, signal as signal4 } from "@angular/core"; var ComponentState = class { isAjaxDataIncoming = signal4(false); enableCheckBoxSelection = signal4(false); isSelectableRowEnabled = signal4(false); isAjaxRequestOutgoing = signal4(false); hasMultipleSelection = signal4(false); isCreateOrUpdateDialogOpen = signal4(false); isUpdateDialogOpen = signal4(false); isCreateDialogOpen = signal4(false); manipulationType = signal4("create" /* Create */); componentTitle = signal4(""); isDataManipulationPageOpen = signal4(false); isCreateOrUpdatePageOpen = signal4(false); isUpdatePageOpen = signal4(false); isCreatePageOpen = signal4(false); manipulationTypeLabel = computed(() => { switch (this.manipulationType()) { case "create" /* Create */: return "Create"; case "update" /* Update */: return "Update"; case "create-child" /* CreateChild */: return "Create Child"; case "delete" /* Delete */: return "Delete"; case "view" /* View */: return "View"; case "save" /* Save */: return "Save"; default: return ""; } }); /** * Updates the component title * @param componentTitle - The new title for the component * @returns This instance for method chaining */ updateComponentTitle = (componentTitle) => { this.componentTitle.set(componentTitle); return this; }; /** * Updates the multiple selection status * @param newStatus - Whether multiple selection is enabled * @returns This instance for method chaining */ updateMultipleSelectionStatus = (newStatus) => { this.hasMultipleSelection.set(newStatus); return this; }; /** * Updates the checkbox selection status * @param newStatus - Whether checkbox selection is enabled * @returns This instance for method chaining */ updateCheckBoxSelectionStatus = (newStatus) => { this.enableCheckBoxSelection.set(newStatus); return this; }; /** * Updates the selectable row status * @param newStatus - Whether row selection is enabled * @returns This instance for method chaining */ updateSelectableRowStatus = (newStatus) => { this.isSelectableRowEnabled.set(newStatus); return this; }; /** * Updates the manipulation type (Create, Update, Delete, View) * @param type - The manipulation type * @returns This instance for method chaining */ updateManipulationType = (type) => { this.manipulationType.set(type); return this; }; /** * Sets the incoming Ajax data status * @param status - Whether Ajax data is incoming * @returns This instance for method chaining */ setAjaxDataIncoming = (status) => { this.isAjaxDataIncoming.set(status); return this; }; /** * Sets the outgoing Ajax request status * @param status - Whether Ajax request is outgoing * @returns This instance for method chaining */ setAjaxRequestOutgoing = (status) => { this.isAjaxRequestOutgoing.set(status); return this; }; /** * Sets the create or update dialog open status * @param status - Whether the dialog is open * @returns This instance for method chaining */ setCreateOrUpdateDialogOpen = (status) => { this.isCreateOrUpdateDialogOpen.set(status); return this; }; /** * Sets the update dialog open status * @param status - Whether the update dialog is open * @returns This instance for method chaining */ setUpdateDialogOpen = (status) => { this.isUpdateDialogOpen.set(status); return this; }; /** * Sets the create dialog open status * @param status - Whether the create dialog is open * @returns This instance for method chaining */ setCreateDialogOpen = (status) => { this.isCreateDialogOpen.set(status); return this; }; /** * Computed signal that combines component title with manipulation type */ componentTitleWithManipulationType = computed(() => { return this.manipulationTypeLabel() + " " + this.componentTitle(); }); /** * Computed signal that indicates if component is in update state */ isOnUpdateState = computed(() => { return this.manipulationType() === "update" /* Update */; }); /** * Computed signal that indicates if component is in create state */ isOnCreateState = computed(() => { return this.manipulationType() === "create" /* Create */; }); /** * Computed signal that indicates if component is in delete state */ isOnDeleteState = computed(() => { return this.manipulationType() === "delete" /* Delete */; }); /** * Computed signal that indicates if component is in view state */ isOnViewState = computed(() => { return this.manipulationType() === "view" /* View */; }); /** * Computed signal that indicates if any Ajax operation is currently running */ isAnyAjaxOperationRunning = computed(() => { return this.isAjaxDataIncoming() || this.isAjaxRequestOutgoing(); }); /** * Computed signal that indicates if any dialog is open */ isAnyDialogOpen = computed(() => { return this.isCreateOrUpdateDialogOpen() || this.isUpdateDialogOpen() || this.isCreateDialogOpen(); }); /** * Resets all state to default values * @returns This instance for method chaining */ reset = () => { this.isAjaxDataIncoming.set(false); this.enableCheckBoxSelection.set(false); this.isSelectableRowEnabled.set(false); this.isAjaxRequestOutgoing.set(false); this.hasMultipleSelection.set(false); this.isCreateOrUpdateDialogOpen.set(false); this.isUpdateDialogOpen.set(false); this.isCreateDialogOpen.set(false); this.manipulationType.set("create" /* Create */); this.componentTitle.set(""); return this; }; }; // src/component-data-storage.ts import { computed as computed2, signal as signal5 } from "@angular/core"; var ComponentDataStorage = class { singleData = signal5(null); multipleData = signal5([]); /** * Patches multiple data by appending new data to the existing array * @param newData - Array of new data to append * @returns This instance for method chaining * * @example * ```typescript * const storage = new ComponentDataStorage<User>(); * storage.updateMultipleData([{ id: 1, name: 'John' }]); * storage.patchMultipleData([{ id: 2, name: 'Jane' }]); * // Result: [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }] * ``` */ patchMultipleData(newData) { this.multipleData.update((prevData) => { return [...prevData, ...newData]; }); return this; } /** * Patches single data by merging new properties with existing data * If no existing data, creates new object with provided data * @param newData - Partial data to merge with existing single data * @returns This instance for method chaining * * @example * ```typescript * const storage = new ComponentDataStorage<User>(); * storage.updateSingleData({ id: 1, name: 'John', email: 'john@example.com' }); * storage.patchSingleData({ email: 'john.doe@example.com' }); * // Result: { id: 1, name: 'John', email: 'john.doe@example.com' } * ``` */ patchSingleData(newData) { this.singleData.update((prev) => prev ? { ...prev, ...newData } : { ...newData }); return this; } /** * Replaces the entire multiple data array * @param newData - New array of data to replace existing data * @returns This instance for method chaining */ updateMultipleData(newData) { this.multipleData.set(newData); return this; } /** * Replaces the single data object * @param newData - New data object or null to replace existing data * @returns This instance for method chaining */ updateSingleData(newData) { this.singleData.set(newData); return this; } /** * Adds a single item to the multiple data array * @param item - Single item to add to the array * @returns This instance for method chaining */ addToMultipleData(item) { this.multipleData.update((prevData) => [...prevData, item]); return this; } /** * Removes an item from the multiple data array based on a predicate function * @param predicate - Function that returns true for items to remove * @returns This instance for method chaining * * @example * ```typescript * storage.removeFromMultipleData(user => user.id === 1); * ``` */ removeFromMultipleData(predicate) { this.multipleData.update((prevData) => prevData.filter((item) => !predicate(item))); return this; } /** * Updates an item in the multiple data array based on a predicate function * @param predicate - Function that returns true for items to update * @param updateFn - Function that returns the updated item * @returns This instance for method chaining * * @example * ```typescript * storage.updateItemInMultipleData( * user => user.id === 1, * user => ({ ...user, name: 'Updated Name' }) * ); * ``` */ updateItemInMultipleData(predicate, updateFn) { this.multipleData.update( (prevData) => prevData.map((item) => predicate(item) ? updateFn(item) : item) ); return this; } /** * Clears all data (both single and multiple) * @returns This instance for method chaining */ clearAll() { this.singleData.set(null); this.multipleData.set([]); return this; } /** * Clears only the single data * @returns This instance for method chaining */ clearSingleData() { this.singleData.set(null); return this; } /** * Clears only the multiple data * @returns This instance for method chaining */ clearMultipleData() { this.multipleData.set([]); return this; } /** * Checks if single data exists (is not null) * @returns true if single data exists, false otherwise */ hasSingleData = computed2(() => { return this.singleData() !== null; }); /** * Checks if multiple data has items * @returns true if multiple data array has items, false if empty */ hasMultipleData = computed2(() => { return !Array.isArray(this.multipleData()) ? false : this.multipleData().length > 0; }); /** * Gets the count of items in multiple data * @returns Number of items in the multiple data array */ getMultipleDataCount = computed2(() => { return !this.hasMultipleData() ? 0 : this.multipleData().length; }); /** * Finds an item in the multiple data array * @param predicate - Function that returns true for the item to find * @returns The found item or undefined */ findInMultipleData(predicate) { return this.multipleData().find(predicate); } /** * Checks if an item exists in the multiple data array * @param predicate - Function that returns true for the item to check * @returns true if item exists, false otherwise */ existsInMultipleData(predicate) { return this.multipleData().some(predicate); } }; // src/ng-select-helper.ts import { HttpClient as HttpClient4, HttpContext as HttpContext4 } from "@angular/common/http"; import { assertInInjectionContext, DestroyRef, inject, Injector, runInInjectionContext, signal as signal6 } from "@angular/core"; import { catchError, debounceTime, finalize, first, mergeMap, of, Subject, switchMap, tap } from "rxjs"; var NgSelectPagedDataResponse = class { constructor(payload, totalCount) { this.payload = payload; this.totalCount = totalCount; } }; var defaultResetOpts = { resetQueryParams: false, resetBody: false, resetCache: false }; var NgSelectHelper = class _NgSelectHelper { constructor(ajaxUrl, httpClient, destroyRef, usePostRequest = false, limit = 50, useCache = true, skipLoadingSpinner = true, initialSearchText = "") { this.ajaxUrl = ajaxUrl; this.httpClient = httpClient; this.destroyRef = destroyRef; this.usePostRequest = usePostRequest; this.useCache = useCache; this.skipLoadingSpinner = skipLoadingSpinner; this.#searchText = initialSearchText; this.#originalAjaxUrl = ajaxUrl; this.#limit = limit > 0 ? limit : 50; this.destroyRef.onDestroy(() => { this.#ajaxErrorSubject.complete(); this.inputSubject.complete(); this.#loadMoreDataSubject.complete(); this.#cache.clear(); if (this.runningApiReq && !this.runningApiReq.closed) { this.runningApiReq.unsubscribe(); } }); } #cache = /* @__PURE__ */ new Map(); #originalAjaxUrl; #queryParams = {}; #body = {}; #initDone = false; #searchText = ""; #limit; #page = 1; #debounceTimeInSec = 1; #totalCount = -1; #isLastApiCallSuccessful = true; #limitReached = false; #loadMoreDataSubject = new Subject(); inputSubject = new Subject(); #ajaxErrorSubject = new Subject(); ajaxError$ = this.#ajaxErrorSubject.asObservable(); #loadedData = signal6( new NgSelectPagedDataResponse([], 0) ); loadedData = this.#loadedData.asReadonly(); #isLoading = signal6(false); isLoading = this.#isLoading.asReadonly(); runningApiReq = null; /** * Creates a new instance of NgSelectHelper * @param options Configuration options * @returns New NgSelectHelper instance */ static create({ ajaxUrl, httpClient, destroyRef, usePostRequest = false, limit = 50, useCache = true, skipLoadingSpinner = true, initialSearchText = "" }) { return new _NgSelectHelper( ajaxUrl, httpClient, destroyRef, usePostRequest, limit, useCache, skipLoadingSpinner, initialSearchText ); } /** * Sets whether to skip the loading spinner for HTTP requests * @param skip Whether to skip the loading spinner * @returns This instance for method chaining */ setSkipLoadingSpinner(skip) { this.skipLoadingSpinner = skip; return this; } /** * Sets the debounce time for search input in seconds * @param debounceTimeInSecond Debounce time in seconds * @returns This instance for method chaining */ setDebounceTimeInSecond(debounceTimeInSecond) { this.#debounceTimeInSec = debounceTimeInSecond > 0 ? debounceTimeInSecond : 1; return this; } /** * Patches the request body (only works with POST requests) * @param value Body data to merge * @returns This instance for method chaining */ patchBody(value) { if (this.usePostRequest) { this.resetAll(); this.#body = Object.assign(this.#body, value); } return this; } /** * Sets the request body (only works with POST requests) * @param newBody New body data * @returns This instance for method chaining */ setBody(newBody) { if (this.usePostRequest) { this.resetAll(); this.#body = newBody; } return this; } /** * Clears the internal cache * @returns This instance for method chaining */ clearCache() { this.#cache.clear(); return this; } /** * Sets route parameters for the URL * @param newRouteParam Route parameter to append * @returns This instance for method chaining */ setRouteParam(newRouteParam) { const baseUrl = this.#originalAjaxUrl.endsWith("/") ? this.#originalAjaxUrl.slice(0, -1) : this.#originalAjaxUrl; let routeParam = newRouteParam.startsWith("/") ? newRouteParam.slice(1) : newRouteParam; this.ajaxUrl = `${baseUrl}/${routeParam}`; return this; } /** * Patches query parameters * @param value Query parameters to merge * @returns This instance for method chaining */ patchQueryParams(value) { this.resetAll(); this.#queryParams = Object.assign(this.#queryParams, value); return this; } /** * Removes a query parameter * @param key Query parameter key to remove * @returns This instance for method chaining */ removeQueryParam(key) { this.resetAll(); delete this.#queryParams[key]; return this; } /** * Sets query parameters * @param newQueryParams New query parameters * @returns This instance for method chaining */ setQueryParams(newQueryParams) { this.resetAll(); this.#queryParams = newQueryParams; return this; } /** * Handler for ng-select blur event */ onBlur() { this.resetAll(); } /** * Handler for ng-select close event */ onClose() { this.resetSearchText(); if (this.runningApiReq && !this.runningApiReq.closed) { this.runningApiReq.unsubscribe(); this.runningApiReq = null; } } /** * Handler for ng-select clear event */ async onClear() { this.resetAll(); } /** * Handler for ng-select open event */ onOpen() { this.#loadMoreDataSubject.next(); } /** * Handler for ng-select scroll to end event */ onScrollToEnd() { if (this.#isLoading()) { return; } if (this.isLastApiCallSuccessful && !this.limitReached) { this.#page += 1; } this.#loadMoreDataSubject.next(); } /** * Gets whether the last API call was successful */ get isLastApiCallSuccessful() { return this.#isLastApiCallSuccessful; } /** * Gets whether the limit has been reached */ get limitReached() { return this.#limitReached; } /** * Gets current query parameters */ get queryParams() { return this.#queryParams; } /** * Gets current request body */ get body() { return this.#body; } /** * Gets current page number */ get page() { return this.#page; } /** * Gets total count of available items */ get totalCount() { return this.#totalCount; } /** * Gets whether initialization is complete */ get isInitDone() { return this.#initDone; } /** * Initializes the NgSelectHelper with event handlers * Should be called in ngOnInit or similar lifecycle method */ init() { if (this.#initDone) { return; } this.#initDone = true; this.inputSubject.pipe( debounceTime(this.#debounceTimeInSec * 500), switchMap((term) => { this.resetAll(); this.#searchText = term; return this.loadDataFromApi(this.page, this.#limit, term).pipe( catchError(() => of(null)) ); }) ).subscribe({ next: (res) => { if (res) { this.updateStateOnSuccessfulInitialApiCall(res); } else { this.updateStateOnFailedApiCall(); } } }); if (this.#searchText !== "") { this.inputSubject.next(this.#searchText); } this.#loadMoreDataSubject.pipe( switchMap(() => { if (this.#isLoading()) { return of(null); } this.runLimitReachedCheck(); if (this.limitReached) { return of(null); } return this.loadDataFromApi(this.page, this.#limit, this.#searchText).pipe( catchError(() => of(null)) ); }) ).subscribe({ next: (res) => { if (res) { this.updateStateOnSuccessfulSubsequentApiCall(res); } else { this.updateStateOnFailedApiCall(); } } }); } /** * Loads data from the API * @param page Page number * @param limit Items per page * @param searchText Search term * @returns Observable of paged data response */ loadDataFromApi(page, limit, searchText) { const queryParams = { page, limit }; if (searchText) { queryParams["searchText"] = searchText; } const key = { ajaxUrl: this.ajaxUrl, page, limit, searchText: searchText ?? "",