Forráskód Böngészése

fix(admin-ui): Enable selective loading of custom fields

This commit introduces some new low-level APIs to the data layer
of the Admin UI. It allows us to control which custom fields
get dynamically added to fragments when making queries & mutations.

It also exposes a new method on the QueryResult class which allows
us to update & refetch the underlying DocumentNode whenever
the selected custom fields changes.

Relates to #3097
Michael Bromley 1 éve
szülő
commit
9d7744b618

+ 46 - 6
packages/admin-ui/src/lib/core/src/common/base-list.component.ts

@@ -2,15 +2,18 @@ import { DestroyRef, Directive, inject, OnDestroy, OnInit } from '@angular/core'
 import { FormControl } from '@angular/forms';
 import { ActivatedRoute, QueryParamsHandling, Router } from '@angular/router';
 import { ResultOf, TypedDocumentNode, VariablesOf } from '@graphql-typed-document-node/core';
-import { BehaviorSubject, combineLatest, merge, Observable, Subject } from 'rxjs';
+import { BehaviorSubject, combineLatest, merge, Observable, Subject, switchMap } from 'rxjs';
 import { debounceTime, distinctUntilChanged, filter, map, shareReplay, takeUntil, tap } from 'rxjs/operators';
 import { DataService } from '../data/providers/data.service';
 
 import { QueryResult } from '../data/query-result';
 import { ServerConfigService } from '../data/server-config';
+import { DataTableConfigService } from '../providers/data-table/data-table-config.service';
 import { DataTableFilterCollection } from '../providers/data-table/data-table-filter-collection';
 import { DataTableSortCollection } from '../providers/data-table/data-table-sort-collection';
 import { PermissionsService } from '../providers/permissions/permissions.service';
+import { DataTable2ColumnComponent } from '../shared/components/data-table-2/data-table-column.component';
+import { DataTableCustomFieldColumnComponent } from '../shared/components/data-table-2/data-table-custom-field-column.component';
 import { CustomFieldConfig, CustomFields, LanguageCode } from './generated-types';
 import { SelectionManager } from './utilities/selection-manager';
 
@@ -58,11 +61,17 @@ export class BaseListComponent<ResultType, ItemType, VariableType extends Record
     private listQueryFn: ListQueryFn<ResultType>;
     private mappingFn: MappingFn<ItemType, ResultType>;
     private onPageChangeFn: OnPageChangeFn<VariableType> = (skip, take) =>
-        ({ options: { skip, take } } as any);
+        ({ options: { skip, take } }) as any;
     protected refresh$ = new BehaviorSubject<undefined>(undefined);
     private defaults: { take: number; skip: number } = { take: 10, skip: 0 };
+    protected visibleCustomFieldColumnChange$ = new Subject<
+        Array<DataTableCustomFieldColumnComponent<any>>
+    >();
 
-    constructor(protected router: Router, protected route: ActivatedRoute) {}
+    constructor(
+        protected router: Router,
+        protected route: ActivatedRoute,
+    ) {}
 
     /**
      * @description
@@ -96,7 +105,7 @@ export class BaseListComponent<ResultType, ItemType, VariableType extends Record
         const fetchPage = ([currentPage, itemsPerPage, _]: [number, number, undefined]) => {
             const take = itemsPerPage;
             const skip = (currentPage - 1) * itemsPerPage;
-            this.listQuery.ref.refetch(this.onPageChangeFn(skip, take));
+            this.listQuery.ref?.refetch(this.onPageChangeFn(skip, take));
         };
 
         this.result$ = this.listQuery.stream$.pipe(shareReplay(1));
@@ -138,7 +147,7 @@ export class BaseListComponent<ResultType, ItemType, VariableType extends Record
     ngOnDestroy() {
         this.destroy$.next();
         this.destroy$.complete();
-        this.listQuery.completed$.next();
+        this.listQuery.destroy();
     }
 
     /**
@@ -157,6 +166,15 @@ export class BaseListComponent<ResultType, ItemType, VariableType extends Record
         this.setQueryParam('perPage', perPage, { replaceUrl: true });
     }
 
+    setVisibleColumns(columns: Array<DataTable2ColumnComponent<any>>) {
+        this.visibleCustomFieldColumnChange$.next(
+            columns.filter(
+                (c): c is DataTableCustomFieldColumnComponent<any> =>
+                    c instanceof DataTableCustomFieldColumnComponent,
+            ),
+        );
+    }
+
     /**
      * @description
      * Re-fetch the current page of results.
@@ -212,8 +230,17 @@ export class TypedBaseListComponent<
     protected router = inject(Router);
     protected serverConfigService = inject(ServerConfigService);
     protected permissionsService = inject(PermissionsService);
+    protected dataTableConfigService = inject(DataTableConfigService);
+    /**
+     * This was introduced to allow us to more easily manage the relation between the
+     * DataTableComponent and the BaseListComponent. It allows the base class to
+     * correctly look up the currently-visible custom field columns, which can then
+     * be passed to the `dataService.query()` method.
+     */
+    protected dataTableListId: string | undefined;
     private refreshStreams: Array<Observable<any>> = [];
     private collections: Array<DataTableFilterCollection | DataTableSortCollection<any>> = [];
+
     constructor() {
         super(inject(Router), inject(ActivatedRoute));
 
@@ -229,8 +256,21 @@ export class TypedBaseListComponent<
         setVariables?: (skip: number, take: number) => VariablesOf<T>;
         refreshListOnChanges?: Array<Observable<any>>;
     }) {
+        const customFieldsChange$ = this.visibleCustomFieldColumnChange$.pipe(
+            map(columns => columns.map(c => c.customField.name)),
+            distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
+        );
+        const includeCustomFields = this.dataTableListId
+            ? this.dataTableConfigService.getConfig(this.dataTableListId).visibility
+            : undefined;
         super.setQueryFn(
-            (args: any) => this.dataService.query(config.document).refetchOnChannelChange(),
+            (args: any) =>
+                this.dataService
+                    .query(config.document, {} as any, 'cache-and-network', {
+                        includeCustomFields,
+                    })
+                    .refetchOnChannelChange()
+                    .refetchOnCustomFieldsChange(customFieldsChange$),
             data => config.getItems(data),
             (skip, take) => config.setVariables?.(skip, take) ?? ({} as any),
         );

+ 9 - 0
packages/admin-ui/src/lib/core/src/data/data.module.ts

@@ -9,6 +9,7 @@ import createUploadLink from 'apollo-upload-client/createUploadLink.mjs';
 import { getAppConfig } from '../app.config';
 import { introspectionResult } from '../common/introspection-result-wrapper';
 import { LocalStorageService } from '../providers/local-storage/local-storage.service';
+import { AddCustomFieldsLink } from './add-custom-fields-link';
 
 import { CheckJobsLink } from './check-jobs-link';
 import { getClientDefaults } from './client-state/client-defaults';
@@ -46,6 +47,13 @@ export function createApollo(
                     },
                 },
             },
+            Query: {
+                fields: {
+                    products: {
+                        merge: (existing, incoming) => incoming,
+                    },
+                },
+            },
         },
     });
     apolloCache.writeQuery({
@@ -61,6 +69,7 @@ export function createApollo(
     return {
         link: ApolloLink.from([
             new OmitTypenameLink(),
+            // new AddCustomFieldsLink(injector.get(ServerConfigService)),
             new CheckJobsLink(injector),
             setContext(() => {
                 const headers: Record<string, string> = {};

+ 25 - 5
packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts

@@ -11,9 +11,27 @@ import { CustomFieldConfig } from '../../common/generated-types';
 import { QueryResult } from '../query-result';
 import { ServerConfigService } from '../server-config';
 import { addCustomFields } from '../utils/add-custom-fields';
+import { isEntityCreateOrUpdateMutation } from '../utils/is-entity-create-or-update-mutation';
 import { removeReadonlyCustomFields } from '../utils/remove-readonly-custom-fields';
 import { transformRelationCustomFieldInputs } from '../utils/transform-relation-custom-field-inputs';
-import { isEntityCreateOrUpdateMutation } from '../utils/is-entity-create-or-update-mutation';
+
+/**
+ * @description
+ * Additional options that can be passed to the `query` and `mutate` methods.
+ *
+ * @since 3.0.4
+ */
+export interface ExtendedQueryOptions {
+    /**
+     * @description
+     * An array of custom field names which should be included in the query or mutation
+     * return data. The automatic inclusion of custom fields is only supported for
+     * entities which are defined as Fragments in the DocumentNode.
+     *
+     * @since 3.0.4
+     */
+    includeCustomFields?: string[];
+}
 
 @Injectable()
 export class BaseDataService {
@@ -33,14 +51,15 @@ export class BaseDataService {
         query: DocumentNode | TypedDocumentNode<T, V>,
         variables?: V,
         fetchPolicy: WatchQueryFetchPolicy = 'cache-and-network',
+        options: ExtendedQueryOptions = {},
     ): QueryResult<T, V> {
-        const withCustomFields = addCustomFields(query, this.customFields);
         const queryRef = this.apollo.watchQuery<T, V>({
-            query: withCustomFields,
+            query: addCustomFields(query, this.customFields, options.includeCustomFields),
             variables,
             fetchPolicy,
         });
-        const queryResult = new QueryResult<T, any>(queryRef, this.apollo);
+
+        const queryResult = new QueryResult<T, V>(queryRef, this.apollo, this.customFields);
         return queryResult;
     }
 
@@ -51,8 +70,9 @@ export class BaseDataService {
         mutation: DocumentNode | TypedDocumentNode<T, V>,
         variables?: V,
         update?: MutationUpdaterFn<T>,
+        options: ExtendedQueryOptions = {},
     ): Observable<T> {
-        const withCustomFields = addCustomFields(mutation, this.customFields);
+        const withCustomFields = addCustomFields(mutation, this.customFields, options.includeCustomFields);
         const withoutReadonlyFields = this.prepareCustomFields(mutation, variables);
 
         return this.apollo

+ 5 - 3
packages/admin-ui/src/lib/core/src/data/providers/data.service.ts

@@ -8,7 +8,7 @@ import { QueryResult } from '../query-result';
 
 import { AdministratorDataService } from './administrator-data.service';
 import { AuthDataService } from './auth-data.service';
-import { BaseDataService } from './base-data.service';
+import { BaseDataService, ExtendedQueryOptions } from './base-data.service';
 import { ClientDataService } from './client-data.service';
 import { CollectionDataService } from './collection-data.service';
 import { CustomerDataService } from './customer-data.service';
@@ -82,8 +82,9 @@ export class DataService {
         query: DocumentNode | TypedDocumentNode<T, V>,
         variables?: V,
         fetchPolicy: WatchQueryFetchPolicy = 'cache-and-network',
+        options: ExtendedQueryOptions = {},
     ): QueryResult<T, V> {
-        return this.baseDataService.query(query, variables, fetchPolicy);
+        return this.baseDataService.query(query, variables, fetchPolicy, options);
     }
 
     /**
@@ -107,7 +108,8 @@ export class DataService {
         mutation: DocumentNode | TypedDocumentNode<T, V>,
         variables?: V,
         update?: MutationUpdaterFn<T>,
+        options: ExtendedQueryOptions = {},
     ): Observable<T> {
-        return this.baseDataService.mutate(mutation, variables, update);
+        return this.baseDataService.mutate(mutation, variables, update, options);
     }
 }

+ 120 - 17
packages/admin-ui/src/lib/core/src/data/query-result.ts

@@ -1,12 +1,14 @@
 import { ApolloQueryResult, NetworkStatus } from '@apollo/client/core';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { Apollo, QueryRef } from 'apollo-angular';
-import { merge, Observable, Subject } from 'rxjs';
-import { distinctUntilChanged, filter, finalize, map, skip, take, takeUntil, tap } from 'rxjs/operators';
+import { DocumentNode } from 'graphql/index';
+import { merge, Observable, Subject, Subscription } from 'rxjs';
+import { distinctUntilChanged, filter, finalize, map, skip, take, takeUntil } from 'rxjs/operators';
 
-import { GetUserStatusQuery } from '../common/generated-types';
+import { CustomFieldConfig, GetUserStatusQuery } from '../common/generated-types';
 
 import { GET_USER_STATUS } from './definitions/client-definitions';
+import { addCustomFields } from './utils/add-custom-fields';
 
 /**
  * @description
@@ -17,12 +19,42 @@ import { GET_USER_STATUS } from './definitions/client-definitions';
  * @docsPage DataService
  */
 export class QueryResult<T, V extends Record<string, any> = Record<string, any>> {
-    constructor(private queryRef: QueryRef<T, V>, private apollo: Apollo) {
-        this.valueChanges = queryRef.valueChanges;
+    constructor(
+        private queryRef: QueryRef<T, V>,
+        private apollo: Apollo,
+        private customFieldMap: Map<string, CustomFieldConfig[]>,
+    ) {
+        this.lastQuery = queryRef.options.query;
     }
 
-    completed$ = new Subject<void>();
-    private valueChanges: Observable<ApolloQueryResult<T>>;
+    /**
+     * Causes any subscriptions to the QueryRef to complete, via the use
+     * of the `takeUntil` operator.
+     */
+    private completed$ = new Subject<void>();
+    /**
+     * The subscription to the current QueryRef.valueChanges Observable.
+     * This is stored so that it can be unsubscribed from when the QueryRef
+     * changes.
+     */
+    private valueChangesSubscription: Subscription;
+    /**
+     * This Subject is used to emit new values from the QueryRef.valueChanges Observable.
+     * We use this rather than directly subscribing to the QueryRef.valueChanges Observable
+     * so that we are able to change the QueryRef and re-subscribe when necessary.
+     */
+    private valueChangeSubject = new Subject<ApolloQueryResult<T>>();
+    /**
+     * We keep track of the QueryRefs which have been subscribed to so that we can avoid
+     * re-subscribing to the same QueryRef multiple times.
+     */
+    private queryRefSubscribed = new WeakMap<QueryRef<T, V>, boolean>();
+    /**
+     * We store a reference to the last query so that we can compare it with the next query
+     * and avoid re-fetching the same query multiple times. This is applicable to the code
+     * paths that actually change the query, i.e. refetchOnCustomFieldsChange().
+     */
+    private lastQuery: DocumentNode;
 
     /**
      * @description
@@ -47,17 +79,44 @@ export class QueryResult<T, V extends Record<string, any> = Record<string, any>>
             takeUntil(this.completed$),
         );
 
-        this.valueChanges = merge(activeChannelId$, this.queryRef.valueChanges).pipe(
-            tap(val => {
+        merge(activeChannelId$, this.valueChangeSubject)
+            .pipe(takeUntil(loggedOut$), takeUntil(this.completed$))
+            .subscribe(val => {
                 if (typeof val === 'string') {
                     new Promise(resolve => setTimeout(resolve, 50)).then(() => this.queryRef.refetch());
                 }
-            }),
-            filter<any>(val => typeof val !== 'string'),
-            takeUntil(loggedOut$),
-            takeUntil(this.completed$),
-        );
-        this.queryRef.valueChanges = this.valueChanges;
+            });
+        return this;
+    }
+
+    /**
+     * @description
+     * Re-fetch this query whenever the custom fields change, updating the query to include the
+     * specified custom fields.
+     *
+     * @since 3.0.4
+     */
+    refetchOnCustomFieldsChange(customFieldsToInclude$: Observable<string[]>): QueryResult<T, V> {
+        customFieldsToInclude$
+            .pipe(
+                filter(customFields => {
+                    const newQuery = addCustomFields(this.lastQuery, this.customFieldMap, customFields);
+                    const hasChanged = JSON.stringify(newQuery) !== JSON.stringify(this.lastQuery);
+                    return hasChanged;
+                }),
+                takeUntil(this.completed$),
+            )
+            .subscribe(customFields => {
+                const newQuery = addCustomFields(this.lastQuery, this.customFieldMap, customFields);
+                this.lastQuery = newQuery;
+                const queryRef = this.apollo.watchQuery<T, V>({
+                    query: newQuery,
+                    variables: this.queryRef.variables,
+                    fetchPolicy: this.queryRef.options.fetchPolicy,
+                });
+                this.queryRef = queryRef;
+                this.subscribeToQueryRef(queryRef);
+            });
         return this;
     }
 
@@ -66,7 +125,7 @@ export class QueryResult<T, V extends Record<string, any> = Record<string, any>>
      * Returns an Observable which emits a single result and then completes.
      */
     get single$(): Observable<T> {
-        return this.valueChanges.pipe(
+        return this.currentQueryRefValueChanges.pipe(
             filter(result => result.networkStatus === NetworkStatus.ready),
             take(1),
             map(result => result.data),
@@ -82,7 +141,7 @@ export class QueryResult<T, V extends Record<string, any> = Record<string, any>>
      * Returns an Observable which emits until unsubscribed.
      */
     get stream$(): Observable<T> {
-        return this.valueChanges.pipe(
+        return this.currentQueryRefValueChanges.pipe(
             filter(result => result.networkStatus === NetworkStatus.ready),
             map(result => result.data),
             finalize(() => {
@@ -111,4 +170,48 @@ export class QueryResult<T, V extends Record<string, any> = Record<string, any>>
     mapStream<R>(mapFn: (item: T) => R): Observable<R> {
         return this.stream$.pipe(map(mapFn));
     }
+
+    /**
+     * @description
+     * Signals to the internal Observable subscriptions that they should complete.
+     */
+    destroy() {
+        this.completed$.next();
+        this.completed$.complete();
+    }
+
+    /**
+     * @description
+     * Returns an Observable which emits the current value of the QueryRef.valueChanges Observable.
+     *
+     * We wrap the valueChanges Observable in a new Observable so that we can have a lazy
+     * evaluation of the valueChanges Observable. That is, we only fire the HTTP request when
+     * the returned Observable is subscribed to.
+     */
+    private get currentQueryRefValueChanges(): Observable<ApolloQueryResult<T>> {
+        return new Observable(subscriber => {
+            if (!this.queryRefSubscribed.get(this.queryRef)) {
+                this.subscribeToQueryRef(this.queryRef);
+                this.queryRefSubscribed.set(this.queryRef, true);
+            }
+            this.valueChangeSubject.subscribe(subscriber);
+            return () => {
+                this.queryRefSubscribed.delete(this.queryRef);
+            };
+        });
+    }
+
+    /**
+     * @description
+     * Subscribes to the valueChanges Observable of the given QueryRef, and stores the subscription
+     * so that it can be unsubscribed from when the QueryRef changes.
+     */
+    private subscribeToQueryRef(queryRef: QueryRef<T, V>) {
+        if (this.valueChangesSubscription) {
+            this.valueChangesSubscription.unsubscribe();
+        }
+        this.valueChangesSubscription = queryRef.valueChanges
+            .pipe(takeUntil(this.completed$))
+            .subscribe(this.valueChangeSubject);
+    }
 }

+ 13 - 0
packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.spec.ts

@@ -186,6 +186,19 @@ describe('addCustomFields()', () => {
         expect((customFieldsDef.selectionSet!.selections[0] as FieldNode).name.value).toBe('custom');
     }
 
+    it('Does not duplicate customFields selection set', () => {
+        const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+        customFieldsConfig.set('Product', [{ name: 'custom', type: 'boolean', list: false }]);
+        const result1 = addCustomFields(documentNode, customFieldsConfig);
+        const result2 = addCustomFields(result1, customFieldsConfig);
+
+        const fragmentDef = result2.definitions[1] as FragmentDefinitionNode;
+        const customFieldSelections = fragmentDef.selectionSet.selections.filter(
+            s => s.kind === Kind.FIELD && s.name.value === 'customFields',
+        );
+        expect(customFieldSelections.length).toBe(1);
+    });
+
     it('Adds customFields to ProductVariant fragment', () => {
         addsCustomFieldsToType('ProductVariant', 2);
     });

+ 57 - 37
packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts

@@ -21,8 +21,10 @@ import {
 export function addCustomFields(
     documentNode: DocumentNode,
     customFields: Map<string, CustomFieldConfig[]>,
+    includeCustomFields?: string[],
 ): DocumentNode {
-    const fragmentDefs = documentNode.definitions.filter(isFragmentDefinition);
+    const clone = JSON.parse(JSON.stringify(documentNode)) as DocumentNode;
+    const fragmentDefs = clone.definitions.filter(isFragmentDefinition);
 
     for (const fragmentDef of fragmentDefs) {
         let entityType = fragmentDef.typeCondition.name.value as keyof Pick<
@@ -43,41 +45,59 @@ export function addCustomFields(
 
         const customFieldsForType = customFields.get(entityType);
         if (customFieldsForType && customFieldsForType.length) {
-            (fragmentDef.selectionSet.selections as SelectionNode[]).push({
-                name: {
-                    kind: Kind.NAME,
-                    value: 'customFields',
-                },
-                kind: Kind.FIELD,
-                selectionSet: {
+            // Check if there is already a customFields field in the fragment
+            // to avoid duplication
+            const existingCustomFieldsField = fragmentDef.selectionSet.selections.find(
+                selection => isFieldNode(selection) && selection.name.value === 'customFields',
+            ) as FieldNode | undefined;
+            const selectionNodes: SelectionNode[] = customFieldsForType
+                .filter(field => !includeCustomFields || includeCustomFields.includes(field.name))
+                .map(
+                    customField =>
+                        ({
+                            kind: Kind.FIELD,
+                            name: {
+                                kind: Kind.NAME,
+                                value: customField.name,
+                            },
+                            // For "relation" custom fields, we need to also select
+                            // all the scalar fields of the related type
+                            ...(customField.type === 'relation'
+                                ? {
+                                      selectionSet: {
+                                          kind: Kind.SELECTION_SET,
+                                          selections: (
+                                              customField as RelationCustomFieldFragment
+                                          ).scalarFields.map(f => ({
+                                              kind: Kind.FIELD,
+                                              name: { kind: Kind.NAME, value: f },
+                                          })),
+                                      },
+                                  }
+                                : {}),
+                        }) as FieldNode,
+                );
+            if (!existingCustomFieldsField) {
+                // If no customFields field exists, add one
+                (fragmentDef.selectionSet.selections as SelectionNode[]).push({
+                    kind: Kind.FIELD,
+                    name: {
+                        kind: Kind.NAME,
+                        value: 'customFields',
+                    },
+                    selectionSet: {
+                        kind: Kind.SELECTION_SET,
+                        selections: selectionNodes,
+                    },
+                });
+            } else {
+                // If a customFields field already exists, add the custom fields
+                // to the existing selection set
+                (existingCustomFieldsField.selectionSet as any) = {
                     kind: Kind.SELECTION_SET,
-                    selections: customFieldsForType.map(
-                        customField =>
-                            ({
-                                kind: Kind.FIELD,
-                                name: {
-                                    kind: Kind.NAME,
-                                    value: customField.name,
-                                },
-                                // For "relation" custom fields, we need to also select
-                                // all the scalar fields of the related type
-                                ...(customField.type === 'relation'
-                                    ? {
-                                          selectionSet: {
-                                              kind: Kind.SELECTION_SET,
-                                              selections: (
-                                                  customField as RelationCustomFieldFragment
-                                              ).scalarFields.map(f => ({
-                                                  kind: Kind.FIELD,
-                                                  name: { kind: Kind.NAME, value: f },
-                                              })),
-                                          },
-                                      }
-                                    : {}),
-                            } as FieldNode),
-                    ),
-                },
-            });
+                    selections: selectionNodes,
+                };
+            }
 
             const localizedFields = customFieldsForType.filter(
                 field => field.type === 'localeString' || field.type === 'localeText',
@@ -104,7 +124,7 @@ export function addCustomFields(
                                         kind: Kind.NAME,
                                         value: customField.name,
                                     },
-                                } as FieldNode),
+                                }) as FieldNode,
                         ),
                     },
                 });
@@ -112,7 +132,7 @@ export function addCustomFields(
         }
     }
 
-    return documentNode;
+    return clone;
 }
 
 function isFragmentDefinition(value: DefinitionNode): value is FragmentDefinitionNode {

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table-custom-field-column.component.html

@@ -1,10 +1,10 @@
 <ng-template let-item="item">
     <ng-container
-        *ngIf="item.customFields[customField.name] == null || item.customFields[customField.name] === ''"
+        *ngIf="!item.customFields || item.customFields[customField.name] == null || item.customFields[customField.name] === ''"
     >
         <span class="empty">-</span>
     </ng-container>
-    <ng-container *ngIf="item.customFields[customField.name] != null">
+    <ng-container *ngIf="item.customFields && item.customFields[customField.name] != null">
         <ng-container [ngSwitch]="customField.type">
             <ng-container *ngSwitchCase="'boolean'">
                 <clr-icon

+ 34 - 20
packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table2.component.ts

@@ -22,8 +22,8 @@ import { Observable, Subject } from 'rxjs';
 import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
 import { LanguageCode } from '../../../common/generated-types';
 import { DataService } from '../../../data/providers/data.service';
-import { DataTableConfigService } from '../../../providers/data-table/data-table-config.service';
 import { DataTableFilterCollection } from '../../../providers/data-table/data-table-filter-collection';
+import { DataTableConfig, LocalStorageService } from '../../../providers/local-storage/local-storage.service';
 import { BulkActionMenuComponent } from '../bulk-action-menu/bulk-action-menu.component';
 
 import { FilterPresetService } from '../data-table-filter-presets/filter-preset.service';
@@ -130,7 +130,6 @@ export class DataTable2Component<T> implements AfterContentInit, OnChanges, OnDe
     route = inject(ActivatedRoute);
     filterPresetService = inject(FilterPresetService);
     dataTableCustomComponentService = inject(DataTableCustomComponentService);
-    dataTableConfigService = inject(DataTableConfigService);
     protected customComponents = new Map<string, { config: DataTableComponentConfig; injector: Injector }>();
 
     rowTemplate: TemplateRef<any>;
@@ -146,6 +145,7 @@ export class DataTable2Component<T> implements AfterContentInit, OnChanges, OnDe
 
     constructor(
         protected changeDetectorRef: ChangeDetectorRef,
+        protected localStorageService: LocalStorageService,
         protected dataService: DataService,
     ) {
         this.uiLanguage$ = this.dataService.client
@@ -167,8 +167,8 @@ export class DataTable2Component<T> implements AfterContentInit, OnChanges, OnDe
 
     get sortedColumns() {
         const columns = this.allColumns;
-        const dataTableConfig = this.dataTableConfigService.getConfig(this.id);
-        for (const [id, index] of Object.entries(dataTableConfig.order)) {
+        const dataTableConfig = this.getDataTableConfig();
+        for (const [id, index] of Object.entries(dataTableConfig[this.id].order)) {
             const column = columns.find(c => c.id === id);
             const currentIndex = columns.findIndex(c => c.id === id);
             if (currentIndex !== -1 && column) {
@@ -212,21 +212,21 @@ export class DataTable2Component<T> implements AfterContentInit, OnChanges, OnDe
 
     ngAfterContentInit(): void {
         this.rowTemplate = this.templateRefs.last;
-        const dataTableConfig = this.dataTableConfigService.getConfig(this.id);
+        const dataTableConfig = this.getDataTableConfig();
 
         if (!this.id) {
             console.warn(`No id was assigned to the data table component`);
         }
         const updateColumnVisibility = () => {
-            dataTableConfig.visibility = this.allColumns
+            dataTableConfig[this.id].visibility = this.allColumns
                 .filter(c => (c.visible && c.hiddenByDefault) || (!c.visible && !c.hiddenByDefault))
                 .map(c => c.id);
-            this.dataTableConfigService.setConfig(this.id, dataTableConfig);
+            this.localStorageService.set('dataTableConfig', dataTableConfig);
             this.visibleColumnsChange.emit(this.visibleSortedColumns);
         };
 
         this.allColumns.forEach(column => {
-            if (dataTableConfig?.visibility.includes(column.id)) {
+            if (dataTableConfig?.[this.id]?.visibility.includes(column.id)) {
                 column.setVisibility(column.hiddenByDefault);
             }
             column.onColumnChange(updateColumnVisibility);
@@ -250,7 +250,8 @@ export class DataTable2Component<T> implements AfterContentInit, OnChanges, OnDe
             this.selectionManager.setCurrentItems(this.items);
         }
         this.showSearchFilterRow =
-            !!this.filters?.activeFilters.length || (dataTableConfig?.showSearchFilterRow ?? false);
+            !!this.filters?.activeFilters.length ||
+            (dataTableConfig?.[this.id]?.showSearchFilterRow ?? false);
         this.columns.changes.subscribe(() => {
             this.changeDetectorRef.markForCheck();
         });
@@ -272,27 +273,27 @@ export class DataTable2Component<T> implements AfterContentInit, OnChanges, OnDe
 
     onColumnReorder(event: { column: DataTable2ColumnComponent<any>; newIndex: number }) {
         const naturalIndex = this.allColumns.findIndex(c => c.id === event.column.id);
-        const dataTableConfig = this.dataTableConfigService.getConfig(this.id);
+        const dataTableConfig = this.getDataTableConfig();
         if (naturalIndex === event.newIndex) {
-            delete dataTableConfig.order[event.column.id];
+            delete dataTableConfig[this.id].order[event.column.id];
         } else {
-            dataTableConfig.order[event.column.id] = event.newIndex;
+            dataTableConfig[this.id].order[event.column.id] = event.newIndex;
         }
-        this.dataTableConfigService.setConfig(this.id, dataTableConfig);
+        this.localStorageService.set('dataTableConfig', dataTableConfig);
     }
 
     onColumnsReset() {
-        const dataTableConfig = this.dataTableConfigService.getConfig(this.id);
-        dataTableConfig.order = {};
-        dataTableConfig.visibility = [];
-        this.dataTableConfigService.setConfig(this.id, dataTableConfig);
+        const dataTableConfig = this.getDataTableConfig();
+        dataTableConfig[this.id].order = {};
+        dataTableConfig[this.id].visibility = [];
+        this.localStorageService.set('dataTableConfig', dataTableConfig);
     }
 
     toggleSearchFilterRow() {
         this.showSearchFilterRow = !this.showSearchFilterRow;
-        const dataTableConfig = this.dataTableConfigService.getConfig(this.id);
-        dataTableConfig.showSearchFilterRow = this.showSearchFilterRow;
-        this.dataTableConfigService.setConfig(this.id, dataTableConfig);
+        const dataTableConfig = this.getDataTableConfig();
+        dataTableConfig[this.id].showSearchFilterRow = this.showSearchFilterRow;
+        this.localStorageService.set('dataTableConfig', dataTableConfig);
     }
 
     trackByFn(index: number, item: any) {
@@ -310,4 +311,17 @@ export class DataTable2Component<T> implements AfterContentInit, OnChanges, OnDe
     onRowClick(item: T, event: MouseEvent) {
         this.selectionManager?.toggleSelection(item, event);
     }
+
+    protected getDataTableConfig(): DataTableConfig {
+        const dataTableConfig = this.localStorageService.get('dataTableConfig') ?? {};
+        if (!dataTableConfig[this.id]) {
+            dataTableConfig[this.id] = {
+                visibility: [],
+                order: {},
+                showSearchFilterRow: false,
+                filterPresets: [],
+            };
+        }
+        return dataTableConfig;
+    }
 }