Browse Source

feat(admin-ui): Add support for custom list filter components

Michael Bromley 2 years ago
parent
commit
0651a70f53
20 changed files with 296 additions and 160 deletions
  1. 17 9
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts
  2. 1 1
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list-bulk-actions.ts
  3. 35 49
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts
  4. 2 2
      packages/admin-ui/src/lib/catalog/src/providers/product-detail/product-detail.service.ts
  5. 11 2
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  6. 14 2
      packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts
  7. 16 8
      packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts
  8. 22 4
      packages/admin-ui/src/lib/core/src/providers/data-table/data-table-filter-collection.ts
  9. 20 1
      packages/admin-ui/src/lib/core/src/providers/data-table/data-table-filter.ts
  10. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/chip/chip.component.scss
  11. 3 0
      packages/admin-ui/src/lib/core/src/shared/components/data-table-filter-label/data-table-filter-label.component.html
  12. 20 2
      packages/admin-ui/src/lib/core/src/shared/components/data-table-filter-label/data-table-filter-label.component.ts
  13. 8 0
      packages/admin-ui/src/lib/core/src/shared/components/data-table-filters/custom-filter-component.directive.ts
  14. 68 58
      packages/admin-ui/src/lib/core/src/shared/components/data-table-filters/data-table-filters.component.html
  15. 42 18
      packages/admin-ui/src/lib/core/src/shared/components/data-table-filters/data-table-filters.component.ts
  16. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/facet-value-chip/facet-value-chip.component.html
  17. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  18. 1 0
      packages/admin-ui/src/lib/static/fonts/fonts.css
  19. 2 1
      packages/admin-ui/src/lib/static/styles/_mixins.scss
  20. 10 1
      packages/admin-ui/src/lib/static/styles/global/_overrides.scss

+ 17 - 9
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts

@@ -148,8 +148,9 @@ export class ProductDetailComponent
             skipUntil(initialVariants$),
             skip(1),
             debounceTime(100),
-            switchMap(([term, currentPage, itemsPerPage]) => this.dataService.product
-                    .getProductVariants(
+            switchMap(([term, currentPage, itemsPerPage]) =>
+                this.dataService.product
+                    .getProductVariantsForProduct(
                         {
                             skip: (currentPage - 1) * itemsPerPage,
                             take: itemsPerPage,
@@ -160,7 +161,8 @@ export class ProductDetailComponent
                         },
                         this.id,
                     )
-                    .mapStream(({ productVariants }) => productVariants)),
+                    .mapStream(({ productVariants }) => productVariants),
+            ),
             shareReplay({ bufferSize: 1, refCount: true }),
         );
         const updatedVariants$ = variantsList$.pipe(map(result => result.items));
@@ -248,13 +250,15 @@ export class ProductDetailComponent
         this.productChannels$
             .pipe(
                 take(1),
-                switchMap(channels => this.modalService.fromComponent(AssignProductsToChannelDialogComponent, {
+                switchMap(channels =>
+                    this.modalService.fromComponent(AssignProductsToChannelDialogComponent, {
                         size: 'lg',
                         locals: {
                             productIds: [this.id],
                             currentChannelIds: channels.map(c => c.id),
                         },
-                    })),
+                    }),
+                ),
             )
             .subscribe();
     }
@@ -262,7 +266,8 @@ export class ProductDetailComponent
     removeFromChannel(channelId: string) {
         from(getChannelCodeFromUserStatus(this.dataService, channelId))
             .pipe(
-                switchMap(({ channelCode }) => this.modalService.dialog({
+                switchMap(({ channelCode }) =>
+                    this.modalService.dialog({
                         title: _('catalog.remove-product-from-channel'),
                         buttons: [
                             { type: 'secondary', label: _('common.cancel') },
@@ -273,7 +278,8 @@ export class ProductDetailComponent
                                 returnValue: true,
                             },
                         ],
-                    })),
+                    }),
+                ),
                 switchMap(response =>
                     response
                         ? this.dataService.product.removeProductsFromChannel({
@@ -309,7 +315,8 @@ export class ProductDetailComponent
     removeVariantFromChannel({ channelId, variant }: { channelId: string; variant: ProductVariantFragment }) {
         from(getChannelCodeFromUserStatus(this.dataService, channelId))
             .pipe(
-                switchMap(({ channelCode }) => this.modalService.dialog({
+                switchMap(({ channelCode }) =>
+                    this.modalService.dialog({
                         title: _('catalog.remove-product-variant-from-channel'),
                         buttons: [
                             { type: 'secondary', label: _('common.cancel') },
@@ -320,7 +327,8 @@ export class ProductDetailComponent
                                 returnValue: true,
                             },
                         ],
-                    })),
+                    }),
+                ),
                 switchMap(response =>
                     response
                         ? this.dataService.product.removeVariantsFromChannel({

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list-bulk-actions.ts

@@ -188,7 +188,7 @@ export const assignFacetValuesToProductsBulkAction: BulkAction<
         const modalService = injector.get(ModalService);
         const dataService = injector.get(DataService);
         const notificationService = injector.get(NotificationService);
-        const mode: 'product' | 'variant' = hostComponent.groupByProduct ? 'product' : 'variant';
+        const mode = 'product';
         const ids = unique(selection.map(p => p.id));
         return modalService
             .fromComponent(BulkAddFacetValuesDialogComponent, {

+ 35 - 49
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts

@@ -1,10 +1,11 @@
-import { Component, OnInit, ViewChild } from '@angular/core';
+import { Component, OnInit } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
     BaseListComponent,
     DataService,
     DataTableService,
+    FacetValueFormInputComponent,
     GetProductListQuery,
     GetProductListQueryVariables,
     ItemOf,
@@ -15,13 +16,11 @@ import {
     NavBuilderService,
     NotificationService,
     ProductFilterParameter,
-    ProductSearchInputComponent,
     ProductSortParameter,
-    SearchProductsQuery,
     ServerConfigService,
 } from '@vendure/admin-ui/core';
-import { EMPTY, Observable } from 'rxjs';
-import { delay, map, switchMap, takeUntil, tap } from 'rxjs/operators';
+import { EMPTY, lastValueFrom, Observable } from 'rxjs';
+import { delay, switchMap, tap } from 'rxjs/operators';
 
 @Component({
     selector: 'vdr-products-list',
@@ -36,10 +35,6 @@ export class ProductListComponent
     >
     implements OnInit
 {
-    facetValueIds: string[] = [];
-    groupByProduct = true;
-    selectedFacetValueIds$: Observable<string[]>;
-    facetValues$: Observable<SearchProductsQuery['search']['facetValues']>;
     availableLanguages$: Observable<LanguageCode[]>;
     contentLanguage$: Observable<LanguageCode>;
     pendingSearchIndexUpdates = 0;
@@ -64,6 +59,37 @@ export class ProductListComponent
             label: _('common.slug'),
             filterField: 'slug',
         })
+        .addFilter({
+            name: 'facetValues',
+            type: {
+                kind: 'custom',
+                component: FacetValueFormInputComponent,
+                serializeValue: value => value.map(v => v.id).join(','),
+                deserializeValue: value => value.split(',').map(id => ({ id })),
+                getLabel: value => {
+                    if (value.length === 0) {
+                        return '';
+                    }
+                    if (value[0].name) {
+                        return value.map(v => v.name).join(', ');
+                    } else {
+                        return lastValueFrom(
+                            this.dataService.facet
+                                .getFacetValues({ filter: { id: { in: value.map(v => v.id) } } })
+                                .mapSingle(({ facetValues }) =>
+                                    facetValues.items.map(fv => fv.name).join(', '),
+                                ),
+                        );
+                    }
+                },
+            },
+            label: _('catalog.facet-values'),
+            toFilterInput: (value: any[]) => ({
+                facetValueId: {
+                    in: value.map(v => v.id),
+                },
+            }),
+        })
         .connectToRoute(this.route);
 
     readonly sorts = this.dataTableService
@@ -76,9 +102,6 @@ export class ProductListComponent
         .addSort({ name: 'slug' })
         .connectToRoute(this.route);
 
-    @ViewChild('productSearchInputComponent', { static: true })
-    private productSearchInput: ProductSearchInputComponent;
-
     constructor(
         private dataService: DataService,
         private modalService: ModalService,
@@ -99,24 +122,6 @@ export class ProductListComponent
             routerLink: ['./create'],
             requiresPermission: ['CreateCatalog', 'CreateProduct'],
         });
-        this.route.queryParamMap
-            .pipe(
-                map(qpm => qpm.get('q')),
-                takeUntil(this.destroy$),
-            )
-            .subscribe(term => {
-                if (this.productSearchInput) {
-                    this.productSearchInput.setSearchTerm(term);
-                }
-            });
-        this.selectedFacetValueIds$ = this.route.queryParamMap.pipe(map(qpm => qpm.getAll('fvids')));
-
-        this.selectedFacetValueIds$.pipe(takeUntil(this.destroy$)).subscribe(ids => {
-            this.facetValueIds = ids;
-            if (this.productSearchInput) {
-                this.productSearchInput.setFacetValues(ids);
-            }
-        });
         super.setQueryFn(
             (args: any) => this.dataService.product.getProducts(args).refetchOnChannelChange(),
             data => data.products,
@@ -139,14 +144,6 @@ export class ProductListComponent
 
     ngOnInit() {
         super.ngOnInit();
-
-        // this.facetValues$ = this.result$.pipe(map(data => data.search.facetValues));
-        //
-        // this.facetValues$
-        //     .pipe(take(1), delay(100), withLatestFrom(this.selectedFacetValueIds$))
-        //     .subscribe(([__, ids]) => {
-        //         this.productSearchInput.setFacetValues(ids);
-        //     });
         this.availableLanguages$ = this.serverConfigService.getAvailableLanguages();
         this.contentLanguage$ = this.dataService.client
             .uiState()
@@ -161,17 +158,6 @@ export class ProductListComponent
         super.refreshListOnChanges(this.contentLanguage$, this.filters.valueChanges, this.sorts.valueChanges);
     }
 
-    setSearchTerm(term: string) {
-        this.setQueryParam({ q: term || null, page: 1 });
-        this.refresh();
-    }
-
-    setFacetValueIds(ids: string[]) {
-        this.facetValueIds = ids;
-        this.setQueryParam({ fvids: ids, page: 1 });
-        this.refresh();
-    }
-
     rebuildSearchIndex() {
         this.dataService.product.reindex().subscribe(({ reindex }) => {
             this.notificationService.info(_('catalog.reindexing'));

+ 2 - 2
packages/admin-ui/src/lib/catalog/src/providers/product-detail/product-detail.service.ts

@@ -155,7 +155,7 @@ export class ProductDetailService {
 
         const variants$ = autoUpdate
             ? this.dataService.product
-                  .getProductVariants({}, product.id)
+                  .getProductVariantsForProduct({}, product.id)
                   .mapSingle(({ productVariants }) => productVariants.items)
             : of([]);
 
@@ -217,7 +217,7 @@ export class ProductDetailService {
     ) {
         const variants$ = input.autoUpdate
             ? this.dataService.product
-                  .getProductVariants({}, product.id)
+                  .getProductVariantsForProduct({}, product.id)
                   .mapSingle(({ productVariants }) => productVariants.items)
             : of([]);
 

+ 11 - 2
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -4419,6 +4419,7 @@ export type ProductFilterParameter = {
   createdAt?: InputMaybe<DateOperators>;
   description?: InputMaybe<StringOperators>;
   enabled?: InputMaybe<BooleanOperators>;
+  facetValueId?: InputMaybe<IdOperators>;
   id?: InputMaybe<IdOperators>;
   languageCode?: InputMaybe<StringOperators>;
   name?: InputMaybe<StringOperators>;
@@ -4585,6 +4586,7 @@ export type ProductVariantFilterParameter = {
   createdAt?: InputMaybe<DateOperators>;
   currencyCode?: InputMaybe<StringOperators>;
   enabled?: InputMaybe<BooleanOperators>;
+  facetValueId?: InputMaybe<IdOperators>;
   id?: InputMaybe<IdOperators>;
   languageCode?: InputMaybe<StringOperators>;
   name?: InputMaybe<StringOperators>;
@@ -7459,13 +7461,20 @@ export type GetProductVariantListSimpleQueryVariables = Exact<{
 
 export type GetProductVariantListSimpleQuery = { productVariants: { __typename?: 'ProductVariantList', totalItems: number, items: Array<{ __typename?: 'ProductVariant', id: string, name: string, sku: string, featuredAsset?: { __typename?: 'Asset', id: string, preview: string, focalPoint?: { __typename?: 'Coordinate', x: number, y: number } | null } | null, product: { __typename?: 'Product', id: string, featuredAsset?: { __typename?: 'Asset', id: string, preview: string, focalPoint?: { __typename?: 'Coordinate', x: number, y: number } | null } | null } }> } };
 
-export type GetProductVariantListQueryVariables = Exact<{
+export type GetProductVariantListForProductQueryVariables = Exact<{
   options: ProductVariantListOptions;
   productId?: InputMaybe<Scalars['ID']>;
 }>;
 
 
-export type GetProductVariantListQuery = { productVariants: { __typename?: 'ProductVariantList', totalItems: number, items: Array<{ __typename?: 'ProductVariant', id: string, createdAt: any, updatedAt: any, enabled: boolean, languageCode: LanguageCode, name: string, price: number, currencyCode: CurrencyCode, priceWithTax: number, stockOnHand: number, stockAllocated: number, trackInventory: GlobalFlag, outOfStockThreshold: number, useGlobalOutOfStockThreshold: boolean, sku: string, taxRateApplied: { __typename?: 'TaxRate', id: string, name: string, value: number }, taxCategory: { __typename?: 'TaxCategory', id: string, name: string }, options: Array<{ __typename?: 'ProductOption', id: string, createdAt: any, updatedAt: any, code: string, languageCode: LanguageCode, name: string, groupId: string, translations: Array<{ __typename?: 'ProductOptionTranslation', id: string, languageCode: LanguageCode, name: string }> }>, facetValues: Array<{ __typename?: 'FacetValue', id: string, code: string, name: string, facet: { __typename?: 'Facet', id: string, name: string } }>, featuredAsset?: { __typename?: 'Asset', id: string, createdAt: any, updatedAt: any, name: string, fileSize: number, mimeType: string, type: AssetType, preview: string, source: string, width: number, height: number, focalPoint?: { __typename?: 'Coordinate', x: number, y: number } | null } | null, assets: Array<{ __typename?: 'Asset', id: string, createdAt: any, updatedAt: any, name: string, fileSize: number, mimeType: string, type: AssetType, preview: string, source: string, width: number, height: number, focalPoint?: { __typename?: 'Coordinate', x: number, y: number } | null }>, translations: Array<{ __typename?: 'ProductVariantTranslation', id: string, languageCode: LanguageCode, name: string }>, channels: Array<{ __typename?: 'Channel', id: string, code: string }> }> } };
+export type GetProductVariantListForProductQuery = { productVariants: { __typename?: 'ProductVariantList', totalItems: number, items: Array<{ __typename?: 'ProductVariant', id: string, createdAt: any, updatedAt: any, enabled: boolean, languageCode: LanguageCode, name: string, price: number, currencyCode: CurrencyCode, priceWithTax: number, stockOnHand: number, stockAllocated: number, trackInventory: GlobalFlag, outOfStockThreshold: number, useGlobalOutOfStockThreshold: boolean, sku: string, taxRateApplied: { __typename?: 'TaxRate', id: string, name: string, value: number }, taxCategory: { __typename?: 'TaxCategory', id: string, name: string }, options: Array<{ __typename?: 'ProductOption', id: string, createdAt: any, updatedAt: any, code: string, languageCode: LanguageCode, name: string, groupId: string, translations: Array<{ __typename?: 'ProductOptionTranslation', id: string, languageCode: LanguageCode, name: string }> }>, facetValues: Array<{ __typename?: 'FacetValue', id: string, code: string, name: string, facet: { __typename?: 'Facet', id: string, name: string } }>, featuredAsset?: { __typename?: 'Asset', id: string, createdAt: any, updatedAt: any, name: string, fileSize: number, mimeType: string, type: AssetType, preview: string, source: string, width: number, height: number, focalPoint?: { __typename?: 'Coordinate', x: number, y: number } | null } | null, assets: Array<{ __typename?: 'Asset', id: string, createdAt: any, updatedAt: any, name: string, fileSize: number, mimeType: string, type: AssetType, preview: string, source: string, width: number, height: number, focalPoint?: { __typename?: 'Coordinate', x: number, y: number } | null }>, translations: Array<{ __typename?: 'ProductVariantTranslation', id: string, languageCode: LanguageCode, name: string }>, channels: Array<{ __typename?: 'Channel', id: string, code: string }> }> } };
+
+export type GetProductVariantListQueryVariables = Exact<{
+  options: ProductVariantListOptions;
+}>;
+
+
+export type GetProductVariantListQuery = { productVariants: { __typename?: 'ProductVariantList', totalItems: number, items: Array<{ __typename?: 'ProductVariant', id: string, createdAt: any, updatedAt: any, enabled: boolean, languageCode: LanguageCode, name: string, price: number, currencyCode: CurrencyCode, priceWithTax: number, trackInventory: GlobalFlag, outOfStockThreshold: number, useGlobalOutOfStockThreshold: boolean, sku: string, stockLevels: Array<{ __typename?: 'StockLevel', id: string, createdAt: any, updatedAt: any, stockLocationId: string, stockOnHand: number, stockAllocated: number, stockLocation: { __typename?: 'StockLocation', id: string, createdAt: any, updatedAt: any, name: string } }>, featuredAsset?: { __typename?: 'Asset', id: string, createdAt: any, updatedAt: any, name: string, fileSize: number, mimeType: string, type: AssetType, preview: string, source: string, width: number, height: number, focalPoint?: { __typename?: 'Coordinate', x: number, y: number } | null } | null }> } };
 
 export type GetTagListQueryVariables = Exact<{
   options?: InputMaybe<TagListOptions>;

+ 14 - 2
packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts

@@ -762,9 +762,21 @@ export const GET_PRODUCT_VARIANT_LIST_SIMPLE = gql`
     }
 `;
 
-export const GET_PRODUCT_VARIANT_LIST = gql`
-    query GetProductVariantList($options: ProductVariantListOptions!, $productId: ID) {
+export const GET_PRODUCT_VARIANT_LIST_FOR_PRODUCT = gql`
+    query GetProductVariantListForProduct($options: ProductVariantListOptions!, $productId: ID) {
         productVariants(options: $options, productId: $productId) {
+            items {
+                ...ProductVariant
+            }
+            totalItems
+        }
+    }
+    ${PRODUCT_VARIANT_FRAGMENT}
+`;
+
+export const GET_PRODUCT_VARIANT_LIST = gql`
+    query GetProductVariantList($options: ProductVariantListOptions!) {
+        productVariants(options: $options) {
             items {
                 id
                 createdAt

+ 16 - 8
packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts

@@ -26,6 +26,7 @@ import {
     GET_PRODUCT_SIMPLE,
     GET_PRODUCT_VARIANT,
     GET_PRODUCT_VARIANT_LIST,
+    GET_PRODUCT_VARIANT_LIST_FOR_PRODUCT,
     GET_PRODUCT_VARIANT_LIST_SIMPLE,
     GET_PRODUCT_VARIANT_OPTIONS,
     GET_PRODUCT_WITH_VARIANTS,
@@ -129,11 +130,18 @@ export class ProductDataService {
         >(GET_PRODUCT_VARIANT_LIST_SIMPLE, { options, productId });
     }
 
-    getProductVariants(options: Codegen.ProductVariantListOptions, productId?: string) {
+    getProductVariants(options: Codegen.ProductVariantListOptions) {
         return this.baseDataService.query<
             Codegen.GetProductVariantListQuery,
             Codegen.GetProductVariantListQueryVariables
-        >(GET_PRODUCT_VARIANT_LIST, { options, productId });
+        >(GET_PRODUCT_VARIANT_LIST, { options });
+    }
+
+    getProductVariantsForProduct(options: Codegen.ProductVariantListOptions, productId: string) {
+        return this.baseDataService.query<
+            Codegen.GetProductVariantListForProductQuery,
+            Codegen.GetProductVariantListForProductQueryVariables
+        >(GET_PRODUCT_VARIANT_LIST_FOR_PRODUCT, { options, productId });
     }
 
     getProductVariant(id: string) {
@@ -206,12 +214,12 @@ export class ProductDataService {
     }
 
     deleteProducts(ids: string[]) {
-        return this.baseDataService.mutate<Codegen.DeleteProductsMutation, Codegen.DeleteProductsMutationVariables>(
-            DELETE_PRODUCTS,
-            {
-                ids,
-            },
-        );
+        return this.baseDataService.mutate<
+            Codegen.DeleteProductsMutation,
+            Codegen.DeleteProductsMutationVariables
+        >(DELETE_PRODUCTS, {
+            ids,
+        });
     }
 
     createProductVariants(input: Codegen.CreateProductVariantInput[]) {

+ 22 - 4
packages/admin-ui/src/lib/core/src/providers/data-table/data-table-filter-collection.ts

@@ -7,6 +7,7 @@ import { DateOperators, NumberOperators, StringOperators } from '../../common/ge
 import {
     DataTableFilter,
     DataTableFilterBooleanType,
+    DataTableFilterCustomType,
     DataTableFilterDateRangeType,
     DataTableFilterNumberType,
     DataTableFilterOptions,
@@ -17,16 +18,25 @@ import {
 } from './data-table-filter';
 
 export class FilterWithValue<Type extends DataTableFilterType = DataTableFilterType> {
+    private onUpdateFns = new Set<(value: DataTableFilterValue<Type>) => void>();
     constructor(
         public readonly filter: DataTableFilter<any, Type>,
         public value: DataTableFilterValue<Type>,
-        private onUpdate?: (value: DataTableFilterValue<Type>) => void,
-    ) {}
+        onUpdate?: (value: DataTableFilterValue<Type>) => void,
+    ) {
+        if (onUpdate) {
+            this.onUpdateFns.add(onUpdate);
+        }
+    }
+
+    onUpdate(fn: (value: DataTableFilterValue<Type>) => void) {
+        this.onUpdateFns.add(fn);
+    }
 
     updateValue(value: DataTableFilterValue<Type>) {
         this.value = value;
-        if (this.onUpdate) {
-            this.onUpdate(value);
+        for (const fn of this.onUpdateFns) {
+            fn(value);
         }
     }
 
@@ -49,6 +59,10 @@ export class FilterWithValue<Type extends DataTableFilterType = DataTableFilterT
     isDateRange(): this is FilterWithValue<DataTableFilterDateRangeType> {
         return this.filter.type.kind === 'dateRange';
     }
+
+    isCustom(): this is FilterWithValue<DataTableFilterCustomType> {
+        return this.filter.type.kind === 'custom';
+    }
 }
 
 export class DataTableFilterCollection<FilterInput extends Record<string, any> = Record<string, any>> {
@@ -173,6 +187,8 @@ export class DataTableFilterCollection<FilterInput extends Record<string, any> =
             const start = val.start ? new Date(val.start).getTime() : '';
             const end = val.end ? new Date(val.end).getTime() : '';
             return `${start},${end}`;
+        } else if (filterWithValue.isCustom()) {
+            return filterWithValue.filter.type.serializeValue(filterWithValue.value);
         }
     }
 
@@ -195,6 +211,8 @@ export class DataTableFilterCollection<FilterInput extends Record<string, any> =
                 const start = startTimestamp ? new Date(Number(startTimestamp)).toISOString() : '';
                 const end = endTimestamp ? new Date(Number(endTimestamp)).toISOString() : '';
                 return { start, end };
+            case 'custom':
+                return filter.type.deserializeValue(value);
             default:
                 assertNever(filter.type);
         }

+ 20 - 1
packages/admin-ui/src/lib/core/src/providers/data-table/data-table-filter.ts

@@ -1,4 +1,6 @@
+import { Type as ComponentType } from '@angular/core';
 import { assertNever } from '@vendure/common/lib/shared-utils';
+import { FormInputComponent } from '../../common/component-registry-types';
 import {
     BooleanOperators,
     DateOperators,
@@ -29,6 +31,14 @@ export interface DataTableFilterDateRangeType {
     kind: 'dateRange';
 }
 
+export interface DataTableFilterCustomType {
+    kind: 'custom';
+    component: ComponentType<FormInputComponent>;
+    serializeValue: (value: any) => string;
+    deserializeValue: (serialized: string) => any;
+    getLabel(value: any): string | Promise<string>;
+}
+
 export type KindValueMap = {
     text: {
         raw: {
@@ -41,13 +51,15 @@ export type KindValueMap = {
     boolean: { raw: boolean; input: BooleanOperators };
     dateRange: { raw: { start?: string; end?: string }; input: DateOperators };
     number: { raw: { operator: keyof NumberOperators; amount: number }; input: NumberOperators };
+    custom: { raw: any; input: any };
 };
 export type DataTableFilterType =
     | DataTableFilterTextType
     | DataTableFilterSelectType
     | DataTableFilterBooleanType
     | DataTableFilterDateRangeType
-    | DataTableFilterNumberType;
+    | DataTableFilterNumberType
+    | DataTableFilterCustomType;
 
 export interface DataTableFilterOptions<
     FilterInput extends Record<string, any> = any,
@@ -124,6 +136,9 @@ export class DataTableFilter<
                 return {
                     [value.operator]: value.term,
                 };
+            case 'custom': {
+                return value;
+            }
             default:
                 assertNever(type);
         }
@@ -167,4 +182,8 @@ export class DataTableFilter<
     isDateRange(): this is DataTableFilter<FilterInput, DataTableFilterDateRangeType> {
         return this.type.kind === 'dateRange';
     }
+
+    isCustom(): this is DataTableFilter<FilterInput, DataTableFilterCustomType> {
+        return this.type.kind === 'custom';
+    }
 }

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/chip/chip.component.scss

@@ -50,7 +50,7 @@
     padding: 5px 8px;
     white-space: nowrap;
     display: flex;
-    align-items: center;
+    align-items: baseline;
 }
 
 .chip-icon {

+ 3 - 0
packages/admin-ui/src/lib/core/src/shared/components/data-table-filter-label/data-table-filter-label.component.html

@@ -48,4 +48,7 @@
             +filterWithValue.value?.amount
         }}</span>
     </ng-container>
+    <ng-container *ngIf="filterWithValue.isCustom()">
+        <span>{{ customFilterLabel$ | async }}</span>
+    </ng-container>
 </div>

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

@@ -1,4 +1,5 @@
-import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
+import { from, merge, Observable, of, Subject, switchMap } from 'rxjs';
 import { DataTableFilter } from '../../../providers/data-table/data-table-filter';
 import { FilterWithValue } from '../../../providers/data-table/data-table-filter-collection';
 
@@ -8,6 +9,23 @@ import { FilterWithValue } from '../../../providers/data-table/data-table-filter
     styleUrls: ['./data-table-filter-label.component.scss'],
     changeDetection: ChangeDetectionStrategy.Default,
 })
-export class DataTableFilterLabelComponent {
+export class DataTableFilterLabelComponent implements OnInit {
     @Input() filterWithValue: FilterWithValue;
+
+    protected customFilterLabel$?: Observable<string>;
+
+    ngOnInit() {
+        const filterValueUpdate$ = new Subject<void>();
+        this.filterWithValue.onUpdate(() => filterValueUpdate$.next());
+        this.customFilterLabel$ = merge(of(this.filterWithValue), filterValueUpdate$).pipe(
+            switchMap(() => {
+                if (this.filterWithValue?.filter.type.kind === 'custom') {
+                    const labelResult = this.filterWithValue.filter.type.getLabel(this.filterWithValue.value);
+                    return typeof labelResult === 'string' ? of(labelResult) : from(labelResult);
+                } else {
+                    return of('');
+                }
+            }),
+        );
+    }
 }

+ 8 - 0
packages/admin-ui/src/lib/core/src/shared/components/data-table-filters/custom-filter-component.directive.ts

@@ -0,0 +1,8 @@
+import { Directive, ViewContainerRef } from '@angular/core';
+
+@Directive({
+    selector: '[vdrCustomFilterComponentHost]',
+})
+export class CustomFilterComponentDirective {
+    constructor(public viewContainerRef: ViewContainerRef) {}
+}

+ 68 - 58
packages/admin-ui/src/lib/core/src/shared/components/data-table-filters/data-table-filters.component.html

@@ -7,7 +7,9 @@
         <button vdrDropdownTrigger class="">
             <span *ngIf="state === 'new'">{{ 'common.add-filter' | translate }}</span>
             <span *ngIf="state === 'active'">
-                <vdr-data-table-filter-label [filterWithValue]="filterWithValue"></vdr-data-table-filter-label>
+                <vdr-data-table-filter-label
+                    [filterWithValue]="filterWithValue"
+                ></vdr-data-table-filter-label>
             </span>
             <clr-icon shape="ellipsis-vertical" size="12"></clr-icon>
         </button>
@@ -21,69 +23,77 @@
                 </button>
             </div>
         </div>
+
+        <div class="filter-heading" *ngIf="selectedFilter">
+            Filter by {{ selectedFilter.label | translate }}:
+        </div>
+        <div class="mx-2 mt-1">
+            <div vdrCustomFilterComponentHost #customComponentHost></div>
+        </div>
         <div *ngIf="selectedFilter" class="">
-            <div class="filter-heading">Filter by {{ selectedFilter.label | translate }}:</div>
-            <div class="mx-2 mt-1" [ngSwitch]="selectedFilter.type.kind">
-                <div *ngSwitchCase="'select'" [formGroup]="formControl">
-                    <label *ngFor="let option of $any(selectedFilter.type).options; index as i">
-                        <input type="checkbox" [formControlName]="i" />
-                        <span>{{ option.label | translate }}</span>
-                    </label>
-                </div>
-                <div *ngSwitchCase="'boolean'">
-                    <label
-                        ><input type="checkbox" [formControl]="formControl" clrToggle />
-                        <span *ngIf="formControl.value">{{ 'common.boolean-true' | translate }}</span>
-                        <span *ngIf="!formControl.value">{{ 'common.boolean-false' | translate }}</span>
-                    </label>
-                </div>
-                <div *ngSwitchCase="'text'">
-                    <div [formGroup]="formControl">
-                        <select clrSelect name="options" formControlName="operator" class="mb-1">
-                            <option value="contains">{{ 'common.operator-contains' | translate }}</option>
-                            <option value="eq">{{ 'common.operator-eq' | translate }}</option>
-                            <option value="notContains">
-                                {{ 'common.operator-not-contains' | translate }}
-                            </option>
-                            <option value="notEq">{{ 'common.operator-not-eq' | translate }}</option>
-                            <option value="regex">{{ 'common.operator-regex' | translate }}</option>
-                        </select>
-                        <input type="text" formControlName="term" />
-                    </div>
-                </div>
-                <div *ngSwitchCase="'number'">
-                    <div [formGroup]="formControl">
-                        <select clrSelect name="options" formControlName="operator" class="mb-1">
-                            <option value="eq">{{ 'common.operator-eq' | translate }}</option>
-                            <option value="gt">{{ 'common.operator-gt' | translate }}</option>
-                            <option value="lt">{{ 'common.operator-lt' | translate }}</option>
-                        </select>
-                        <input
-                            *ngIf="$any(selectedFilter.type).inputType !== 'currency'"
-                            type="text"
-                            formControlName="amount"
-                        />
-                        <vdr-currency-input
-                            *ngIf="$any(selectedFilter.type).inputType === 'currency'"
-                            formControlName="amount"
-                        />
-                    </div>
-                </div>
-                <div *ngSwitchCase="'dateRange'">
-                    <div [formGroup]="formControl">
-                        <label>
-                            <div>{{ 'common.start-date' | translate }}</div>
+            <ng-container *ngIf="selectedFilter.type.kind !== 'custom'">
+                <div class="mx-2 mt-1" [ngSwitch]="selectedFilter.type.kind">
+                    <div *ngSwitchCase="'select'" [formGroup]="formControl">
+                        <label *ngFor="let option of $any(selectedFilter.type).options; index as i">
+                            <input type="checkbox" [formControlName]="i" />
+                            <span>{{ option.label | translate }}</span>
                         </label>
-                        <vdr-datetime-picker formControlName="start"></vdr-datetime-picker>
-                        <label>
-                            <div>{{ 'common.end-date' | translate }}</div>
+                    </div>
+                    <div *ngSwitchCase="'boolean'">
+                        <label
+                            ><input type="checkbox" [formControl]="formControl" clrToggle />
+                            <span *ngIf="formControl.value">{{ 'common.boolean-true' | translate }}</span>
+                            <span *ngIf="!formControl.value">{{ 'common.boolean-false' | translate }}</span>
                         </label>
-                        <vdr-datetime-picker formControlName="end"></vdr-datetime-picker>
+                    </div>
+                    <div *ngSwitchCase="'text'">
+                        <div [formGroup]="formControl">
+                            <select clrSelect name="options" formControlName="operator" class="mb-1">
+                                <option value="contains">{{ 'common.operator-contains' | translate }}</option>
+                                <option value="eq">{{ 'common.operator-eq' | translate }}</option>
+                                <option value="notContains">
+                                    {{ 'common.operator-not-contains' | translate }}
+                                </option>
+                                <option value="notEq">{{ 'common.operator-not-eq' | translate }}</option>
+                                <option value="regex">{{ 'common.operator-regex' | translate }}</option>
+                            </select>
+                            <input type="text" formControlName="term" />
+                        </div>
+                    </div>
+                    <div *ngSwitchCase="'number'">
+                        <div [formGroup]="formControl">
+                            <select clrSelect name="options" formControlName="operator" class="mb-1">
+                                <option value="eq">{{ 'common.operator-eq' | translate }}</option>
+                                <option value="gt">{{ 'common.operator-gt' | translate }}</option>
+                                <option value="lt">{{ 'common.operator-lt' | translate }}</option>
+                            </select>
+                            <input
+                                *ngIf="$any(selectedFilter.type).inputType !== 'currency'"
+                                type="text"
+                                formControlName="amount"
+                            />
+                            <vdr-currency-input
+                                *ngIf="$any(selectedFilter.type).inputType === 'currency'"
+                                formControlName="amount"
+                            />
+                        </div>
+                    </div>
+                    <div *ngSwitchCase="'dateRange'">
+                        <div [formGroup]="formControl">
+                            <label>
+                                <div>{{ 'common.start-date' | translate }}</div>
+                            </label>
+                            <vdr-datetime-picker formControlName="start"></vdr-datetime-picker>
+                            <label>
+                                <div>{{ 'common.end-date' | translate }}</div>
+                            </label>
+                            <vdr-datetime-picker formControlName="end"></vdr-datetime-picker>
+                        </div>
                     </div>
                 </div>
-            </div>
+            </ng-container>
             <div class="apply-wrapper mt-2">
-                <button class="button" (click)="activate()" [disabled]="!formControl.valid">
+                <button class="button" (click)="activate()" [disabled]="!formControl?.valid">
                     <span>{{ 'common.apply' | translate }}</span>
                     <clr-icon shape="check"></clr-icon>
                 </button>

+ 42 - 18
packages/admin-ui/src/lib/core/src/shared/components/data-table-filters/data-table-filters.component.ts

@@ -1,19 +1,24 @@
-import { AfterViewInit, ChangeDetectionStrategy, Component, Input, OnInit, ViewChild } from '@angular/core';
-import { AbstractControl, FormArray, FormControl, FormGroup } from '@angular/forms';
 import {
-    DataTableFilterType,
-    DataTableFilterValue,
-    DateOperators,
-    KindValueMap,
-} from '@vendure/admin-ui/core';
+    AfterViewInit,
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    ComponentRef,
+    Input,
+    ViewChild,
+} from '@angular/core';
+import { AbstractControl, FormArray, FormControl, FormGroup } from '@angular/forms';
 import { assertNever } from '@vendure/common/lib/shared-utils';
-import { DataTableFilter } from '../../../providers/data-table/data-table-filter';
+import { FormInputComponent } from '../../../common/component-registry-types';
+import { DateOperators } from '../../../common/generated-types';
+import { DataTableFilter, KindValueMap } from '../../../providers/data-table/data-table-filter';
 import {
     DataTableFilterCollection,
     FilterWithValue,
 } from '../../../providers/data-table/data-table-filter-collection';
 import { I18nService } from '../../../providers/i18n/i18n.service';
 import { DropdownComponent } from '../dropdown/dropdown.component';
+import { CustomFilterComponentDirective } from './custom-filter-component.directive';
 
 @Component({
     selector: 'vdr-data-table-filters',
@@ -21,23 +26,21 @@ import { DropdownComponent } from '../dropdown/dropdown.component';
     styleUrls: ['./data-table-filters.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class DataTableFiltersComponent implements AfterViewInit, OnInit {
+export class DataTableFiltersComponent implements AfterViewInit {
     @Input() filters: DataTableFilterCollection;
     @Input() filterWithValue?: FilterWithValue;
     @ViewChild('dropdown', { static: true }) dropdown: DropdownComponent;
+    @ViewChild('customComponentHost', { static: false, read: CustomFilterComponentDirective })
+    set customComponentHost(content: CustomFilterComponentDirective) {
+        this._customComponentHost = content;
+    }
+    _customComponentHost: CustomFilterComponentDirective;
     protected state: 'new' | 'active' = 'new';
     protected formControl: AbstractControl;
     protected selectedFilter: DataTableFilter | undefined;
+    protected customComponent?: ComponentRef<FormInputComponent>;
 
-    constructor(private i18nService: I18nService) {}
-
-    ngOnInit() {
-        if (this.filterWithValue) {
-            const { filter, value } = this.filterWithValue;
-            this.selectFilter(filter, value);
-            this.state = 'active';
-        }
-    }
+    constructor(private i18nService: I18nService, private changeDetectorRef: ChangeDetectorRef) {}
 
     ngAfterViewInit() {
         this.dropdown.onOpenChange(isOpen => {
@@ -45,6 +48,12 @@ export class DataTableFiltersComponent implements AfterViewInit, OnInit {
                 this.selectedFilter = undefined;
             }
         });
+        if (this.filterWithValue) {
+            const { filter, value } = this.filterWithValue;
+            this.selectFilter(filter, value);
+            this.state = 'active';
+        }
+        setTimeout(() => this.changeDetectorRef.markForCheck());
     }
 
     selectFilter(filter: DataTableFilter, value?: any) {
@@ -100,6 +109,14 @@ export class DataTableFiltersComponent implements AfterViewInit, OnInit {
                     return null;
                 },
             );
+        } else if (filter.isCustom() && this._customComponentHost) {
+            // this.#customComponentHost.viewContainerRef.clear();
+            this.customComponent = this._customComponentHost.viewContainerRef.createComponent(
+                filter.type.component,
+            );
+            this.formControl = new FormControl<any>(value ?? []);
+            this.customComponent.instance.config = {};
+            this.customComponent.instance.formControl = new FormControl<any>(value ?? []);
         }
     }
 
@@ -152,6 +169,13 @@ export class DataTableFiltersComponent implements AfterViewInit, OnInit {
                     term: this.formControl.value.term,
                 } as KindValueMap[typeof type.kind]['raw'];
                 break;
+            case 'custom':
+                value = this.customComponent?.instance.formControl.value;
+                this.formControl.setValue(value);
+                if (this.state === 'new') {
+                    this._customComponentHost.viewContainerRef.clear();
+                }
+                break;
             default:
                 assertNever(type);
         }

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/facet-value-chip/facet-value-chip.component.html

@@ -5,5 +5,5 @@
     [title]="facetValue.facet.name + ' - ' + facetValue.name"
 >
     <span *ngIf="displayFacetName" class="facet-name">{{ facetValue.facet.name }}</span>
-    {{ facetValue.name }}
+    <span>{{ facetValue.name }}</span>
 </vdr-chip>

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -44,6 +44,7 @@ import { DataTable2SearchComponent } from './components/data-table-2/data-table-
 import { DataTable2Component } from './components/data-table-2/data-table2.component';
 import { DataTableColumnPickerComponent } from './components/data-table-column-picker/data-table-column-picker.component';
 import { DataTableFilterLabelComponent } from './components/data-table-filter-label/data-table-filter-label.component';
+import { CustomFilterComponentDirective } from './components/data-table-filters/custom-filter-component.directive';
 import { DataTableFiltersComponent } from './components/data-table-filters/data-table-filters.component';
 import { DataTableColumnComponent } from './components/data-table/data-table-column.component';
 import { DataTableComponent } from './components/data-table/data-table.component';
@@ -280,6 +281,7 @@ const DECLARATIONS = [
     SplitViewLeftDirective,
     SplitViewRightDirective,
     PageComponent,
+    CustomFilterComponentDirective,
 ];
 
 const DYNAMIC_FORM_INPUTS = [

+ 1 - 0
packages/admin-ui/src/lib/static/fonts/fonts.css

@@ -253,4 +253,5 @@
 
 html, body:not([cds-text]) {
     font-family: Inter, sans-serif !important;
+    font-size: var(--font-size-base);
 }

+ 2 - 1
packages/admin-ui/src/lib/static/styles/_mixins.scss

@@ -23,7 +23,8 @@
     }
 
     .ng-select.ng-select-multiple .ng-select-container .ng-value-container {
-        padding: 0;
+        padding: 0 3px;
+        gap: 3px;
     }
 
     .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-placeholder {

+ 10 - 1
packages/admin-ui/src/lib/static/styles/global/_overrides.scss

@@ -5,6 +5,15 @@
     background-color: var(--clr-global-app-background);
 }
 
+h1:not([cds-text]),
+h2:not([cds-text]),
+h3:not([cds-text]),
+h4:not([cds-text]),
+h5:not([cds-text]),
+h6:not([cds-text]) {
+    font-family: Inter, sans-serif !important;
+}
+
 a:link, a:visited {
     color: var(--clr-global-link-color);
     text-decoration: none;
@@ -73,5 +82,5 @@ button.icon-button {
 }
 
 .cdk-overlay-container {
-    z-index: 1060;
+    z-index: 1050;
 }