Parcourir la source

feat(dashboard): First draft of collection detail

Michael Bromley il y a 10 mois
Parent
commit
427d960f21

+ 24 - 0
packages/dashboard/src/components/shared/configurable-operation-arg-input.tsx

@@ -0,0 +1,24 @@
+import { ConfigurableOperationDefFragment } from "@/graphql/fragments.js";
+import { Input } from "../ui/input.js";
+import { ConfigArgType } from "@vendure/core";
+import { Checkbox } from "../ui/checkbox.js";
+import { FacetValueSelector } from "./facet-value-selector.js";
+export interface ConfigurableOperationArgInputProps {
+    definition: ConfigurableOperationDefFragment['args'][number];
+    value: string;
+    onChange: (value: any) => void;
+}
+
+export function ConfigurableOperationArgInput({ definition, value, onChange }: ConfigurableOperationArgInputProps) {
+    if ((definition.ui as any)?.component === 'facet-value-form-input') {
+        return <FacetValueSelector onValueSelect={value => onChange(value)} />
+    }
+    switch (definition.type as ConfigArgType) {
+        case 'boolean':
+            return <Checkbox value={value} onCheckedChange={state => onChange(state)} />;
+        case 'string':
+            return <Input value={value} onChange={e => onChange(e.target.value)} />;
+        default:
+            return <Input value={value} onChange={e => onChange(e.target.value)} />;
+    }
+}

+ 121 - 0
packages/dashboard/src/components/shared/configurable-operation-input.tsx

@@ -0,0 +1,121 @@
+import { ConfigurableOperationDefFragment, ConfigurableOperationFragment } from '@/graphql/fragments.js';
+import { useForm } from 'react-hook-form';
+import * as React from 'react';
+import { Form, FormControl, FormField, FormItem, FormLabel } from '../ui/form.js';
+import { Input } from '../ui/input.js';
+import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
+import { ConfigurableOperationArgInput } from './configurable-operation-arg-input.js';
+
+export interface ConfigurableOperationInputProps {
+    operationDefinition: ConfigurableOperationDefFragment;
+    readonly?: boolean;
+    removable?: boolean;
+    position?: number;
+    hideDescription?: boolean;
+    value: ConfigurableOperationInputType;
+    onChange: (val: ConfigurableOperationInputType) => void;
+}
+
+export function ConfigurableOperationInput({
+    operationDefinition,
+    readonly,
+    removable,
+    position,
+    hideDescription,
+    value,
+    onChange,
+}: ConfigurableOperationInputProps) {
+    const form = useForm({
+        defaultValues: {
+            ...value,
+        },
+    });
+
+    const handleInputChange = (name: string, inputValue: string) => {
+        const argIndex = value.arguments.findIndex(arg => arg.name === name);
+        const stringValue = inputValue.toString();
+        let updatedArgs: ConfigurableOperationInputType['arguments'];
+        if (argIndex === -1) {
+            updatedArgs = [...value.arguments, { name, value: stringValue }];
+        } else {
+            updatedArgs = value.arguments.map(arg => (arg.name === name ? { ...arg, value: stringValue } : arg));
+        }
+        const newVal: ConfigurableOperationInputType = { ...value, arguments: updatedArgs };
+        onChange(newVal);
+    };
+
+    return (
+        <Form {...form}>
+            <div className="space-y-4">
+                {!hideDescription && (
+                    <div className="font-medium">
+                        {' '}
+                        {interpolateDescription(operationDefinition, value.arguments)}
+                    </div>
+                )}
+                <div className="grid grid-cols-2 gap-4">
+                    {operationDefinition.args.map(arg => {
+                        const argValue = value.arguments.find(a => a.name === arg.name)?.value || '';
+                        return (
+                            <FormField
+                                key={arg.name}
+                                name={`args.${arg.name}`}
+                                render={() => (
+                                <FormItem>
+                                    <FormLabel>{arg.name}</FormLabel>
+                                    <FormControl>
+                                        <ConfigurableOperationArgInput
+                                            definition={arg}
+                                            value={argValue}
+                                            onChange={value => handleInputChange(arg.name, value)}
+                                        />
+                                        </FormControl>
+                                    </FormItem>
+                                )}
+                            />
+                        );
+                    })}
+                </div>
+            </div>
+        </Form>
+    );
+}
+
+/**
+ * Interpolates the description of an ConfigurableOperation with the given values.
+ */
+export function interpolateDescription(
+    operation: any,
+    values: { [name: string]: any },
+    precisionFactor = 2,
+): string {
+    if (!operation) {
+        return '';
+    }
+    const templateString = operation.description;
+    const interpolated = templateString.replace(
+        /{\s*([a-zA-Z0-9]+)\s*}/gi,
+        (substring: string, argName: string) => {
+            const normalizedArgName = argName.toLowerCase();
+            const value = values[normalizedArgName];
+            if (value == null) {
+                return '_';
+            }
+            let formatted = value;
+            const argDef = operation.args.find((arg: any) => arg.name === normalizedArgName);
+            if (
+                argDef &&
+                argDef.type === 'int' &&
+                argDef.ui &&
+                argDef.ui.component === 'currency-form-input'
+            ) {
+                formatted = value / Math.pow(10, precisionFactor);
+            }
+            if (argDef && argDef.type === 'datetime' && value instanceof Date) {
+                formatted = value.toLocaleDateString();
+            }
+            return formatted;
+        },
+    );
+    return interpolated;
+}

+ 31 - 0
packages/dashboard/src/graphql/fragments.tsx

@@ -21,3 +21,34 @@ export const assetFragment = graphql(`
 `);
 
 export type AssetFragment = ResultOf<typeof assetFragment>;
+
+export const configurableOperationFragment = graphql(`
+    fragment ConfigurableOperation on ConfigurableOperation {
+        args {
+            name
+            value
+        }
+        code
+    }
+`);
+
+export type ConfigurableOperationFragment = ResultOf<typeof configurableOperationFragment>;
+
+export const configurableOperationDefFragment = graphql(`
+    fragment ConfigurableOperationDef on ConfigurableOperationDefinition {
+        args {
+            name
+            type
+            required
+            defaultValue
+            list
+            ui
+            label
+            description
+        }
+        code
+        description
+    }
+`);
+
+export type ConfigurableOperationDefFragment = ResultOf<typeof configurableOperationDefFragment>;

+ 27 - 0
packages/dashboard/src/routeTree.gen.ts

@@ -23,6 +23,7 @@ import { Route as AuthenticatedCollectionsCollectionsImport } from './routes/_au
 import { Route as AuthenticatedProductsProductsIdImport } from './routes/_authenticated/_products/products_.$id';
 import { Route as AuthenticatedProductVariantsProductVariantsIdImport } from './routes/_authenticated/_product-variants/product-variants_.$id';
 import { Route as AuthenticatedFacetsFacetsIdImport } from './routes/_authenticated/_facets/facets_.$id';
+import { Route as AuthenticatedCollectionsCollectionsIdImport } from './routes/_authenticated/_collections/collections_.$id';
 
 // Create/Update Routes
 
@@ -99,6 +100,12 @@ const AuthenticatedFacetsFacetsIdRoute = AuthenticatedFacetsFacetsIdImport.updat
     getParentRoute: () => AuthenticatedRoute,
 } as any);
 
+const AuthenticatedCollectionsCollectionsIdRoute = AuthenticatedCollectionsCollectionsIdImport.update({
+    id: '/_collections/collections_/$id',
+    path: '/collections/$id',
+    getParentRoute: () => AuthenticatedRoute,
+} as any);
+
 // Populate the FileRoutesByPath interface
 
 declare module '@tanstack/react-router' {
@@ -166,6 +173,13 @@ declare module '@tanstack/react-router' {
             preLoaderRoute: typeof AuthenticatedProductsProductsImport;
             parentRoute: typeof AuthenticatedImport;
         };
+        '/_authenticated/_collections/collections_/$id': {
+            id: '/_authenticated/_collections/collections_/$id';
+            path: '/collections/$id';
+            fullPath: '/collections/$id';
+            preLoaderRoute: typeof AuthenticatedCollectionsCollectionsIdImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
         '/_authenticated/_facets/facets_/$id': {
             id: '/_authenticated/_facets/facets_/$id';
             path: '/facets/$id';
@@ -199,6 +213,7 @@ interface AuthenticatedRouteChildren {
     AuthenticatedFacetsFacetsRoute: typeof AuthenticatedFacetsFacetsRoute;
     AuthenticatedProductVariantsProductVariantsRoute: typeof AuthenticatedProductVariantsProductVariantsRoute;
     AuthenticatedProductsProductsRoute: typeof AuthenticatedProductsProductsRoute;
+    AuthenticatedCollectionsCollectionsIdRoute: typeof AuthenticatedCollectionsCollectionsIdRoute;
     AuthenticatedFacetsFacetsIdRoute: typeof AuthenticatedFacetsFacetsIdRoute;
     AuthenticatedProductVariantsProductVariantsIdRoute: typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     AuthenticatedProductsProductsIdRoute: typeof AuthenticatedProductsProductsIdRoute;
@@ -211,6 +226,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
     AuthenticatedFacetsFacetsRoute: AuthenticatedFacetsFacetsRoute,
     AuthenticatedProductVariantsProductVariantsRoute: AuthenticatedProductVariantsProductVariantsRoute,
     AuthenticatedProductsProductsRoute: AuthenticatedProductsProductsRoute,
+    AuthenticatedCollectionsCollectionsIdRoute: AuthenticatedCollectionsCollectionsIdRoute,
     AuthenticatedFacetsFacetsIdRoute: AuthenticatedFacetsFacetsIdRoute,
     AuthenticatedProductVariantsProductVariantsIdRoute: AuthenticatedProductVariantsProductVariantsIdRoute,
     AuthenticatedProductsProductsIdRoute: AuthenticatedProductsProductsIdRoute,
@@ -228,6 +244,7 @@ export interface FileRoutesByFullPath {
     '/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
+    '/collections/$id': typeof AuthenticatedCollectionsCollectionsIdRoute;
     '/facets/$id': typeof AuthenticatedFacetsFacetsIdRoute;
     '/product-variants/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
@@ -242,6 +259,7 @@ export interface FileRoutesByTo {
     '/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
+    '/collections/$id': typeof AuthenticatedCollectionsCollectionsIdRoute;
     '/facets/$id': typeof AuthenticatedFacetsFacetsIdRoute;
     '/product-variants/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
@@ -258,6 +276,7 @@ export interface FileRoutesById {
     '/_authenticated/_facets/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/_authenticated/_product-variants/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/_authenticated/_products/products': typeof AuthenticatedProductsProductsRoute;
+    '/_authenticated/_collections/collections_/$id': typeof AuthenticatedCollectionsCollectionsIdRoute;
     '/_authenticated/_facets/facets_/$id': typeof AuthenticatedFacetsFacetsIdRoute;
     '/_authenticated/_product-variants/product-variants_/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     '/_authenticated/_products/products_/$id': typeof AuthenticatedProductsProductsIdRoute;
@@ -275,6 +294,7 @@ export interface FileRouteTypes {
         | '/facets'
         | '/product-variants'
         | '/products'
+        | '/collections/$id'
         | '/facets/$id'
         | '/product-variants/$id'
         | '/products/$id';
@@ -288,6 +308,7 @@ export interface FileRouteTypes {
         | '/facets'
         | '/product-variants'
         | '/products'
+        | '/collections/$id'
         | '/facets/$id'
         | '/product-variants/$id'
         | '/products/$id';
@@ -302,6 +323,7 @@ export interface FileRouteTypes {
         | '/_authenticated/_facets/facets'
         | '/_authenticated/_product-variants/product-variants'
         | '/_authenticated/_products/products'
+        | '/_authenticated/_collections/collections_/$id'
         | '/_authenticated/_facets/facets_/$id'
         | '/_authenticated/_product-variants/product-variants_/$id'
         | '/_authenticated/_products/products_/$id';
@@ -342,6 +364,7 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
         "/_authenticated/_facets/facets",
         "/_authenticated/_product-variants/product-variants",
         "/_authenticated/_products/products",
+        "/_authenticated/_collections/collections_/$id",
         "/_authenticated/_facets/facets_/$id",
         "/_authenticated/_product-variants/product-variants_/$id",
         "/_authenticated/_products/products_/$id"
@@ -377,6 +400,10 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "filePath": "_authenticated/_products/products.tsx",
       "parent": "/_authenticated"
     },
+    "/_authenticated/_collections/collections_/$id": {
+      "filePath": "_authenticated/_collections/collections_.$id.tsx",
+      "parent": "/_authenticated"
+    },
     "/_authenticated/_facets/facets_/$id": {
       "filePath": "_authenticated/_facets/facets_.$id.tsx",
       "parent": "/_authenticated"

+ 80 - 1
packages/dashboard/src/routes/_authenticated/_collections/collections.graphql.ts

@@ -1,4 +1,8 @@
-import { assetFragment } from '@/graphql/fragments.js';
+import {
+    assetFragment,
+    configurableOperationDefFragment,
+    configurableOperationFragment,
+} from '@/graphql/fragments.js';
 import { graphql } from '@/graphql/graphql.js';
 
 export const collectionListDocument = graphql(
@@ -32,3 +36,78 @@ export const collectionListDocument = graphql(
     `,
     [assetFragment],
 );
+
+export const collectionDetailDocument = graphql(
+    `
+        query CollectionDetail($id: ID!) {
+            collection(id: $id) {
+                id
+                createdAt
+                updatedAt
+                name
+                slug
+                description
+                isPrivate
+                languageCode
+                breadcrumbs {
+                    id
+                    name
+                    slug
+                }
+                featuredAsset {
+                    ...Asset
+                }
+                assets {
+                    ...Asset
+                }
+                inheritFilters
+                filters {
+                    ...ConfigurableOperation
+                }
+                translations {
+                    id
+                    languageCode
+                    name
+                    slug
+                    description
+                }
+                parent {
+                    id
+                    name
+                }
+                children {
+                    id
+                    name
+                }
+            }
+        }
+    `,
+    [assetFragment, configurableOperationFragment],
+);
+
+export const createCollectionDocument = graphql(`
+    mutation CreateCollection($input: CreateCollectionInput!) {
+        createCollection(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updateCollectionDocument = graphql(`
+    mutation UpdateCollection($input: UpdateCollectionInput!) {
+        updateCollection(input: $input) {
+            id
+        }
+    }
+`);
+
+export const getCollectionFiltersDocument = graphql(
+    `
+        query GetCollectionFilters {
+            collectionFilters {
+                ...ConfigurableOperationDef
+            }
+        }
+    `,
+    [configurableOperationDefFragment],
+);

+ 3 - 4
packages/dashboard/src/routes/_authenticated/_collections/collections.tsx

@@ -48,13 +48,12 @@ export function CollectionListPage() {
                     header: 'Contents',
                     cell: ({ row }) => {
                         return (
-                            <Badge variant="outline">
-                                <div><Trans>{row.original.productVariants.totalItems} variants</Trans></div>
                                 <CollectionContentsSheet
                                     collectionId={row.original.id}
                                     collectionName={row.original.name}
-                                />
-                            </Badge>
+                                >
+                                     <Trans>{row.original.productVariants.totalItems} variants</Trans>
+                                </CollectionContentsSheet>
                         );
                     },
                 },

+ 285 - 0
packages/dashboard/src/routes/_authenticated/_collections/collections_.$id.tsx

@@ -0,0 +1,285 @@
+import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
+import { Button } from '@/components/ui/button.js';
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from '@/components/ui/form.js';
+import { Input } from '@/components/ui/input.js';
+import { Switch } from '@/components/ui/switch.js';
+import { NEW_ENTITY_PATH } from '@/constants.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import {
+    CustomFieldsPageBlock,
+    Page,
+    PageActionBar,
+    PageBlock,
+    PageLayout,
+    PageTitle,
+} from '@/framework/layout-engine/page-layout.js';
+import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { toast } from 'sonner';
+import {
+    collectionDetailDocument,
+    createCollectionDocument,
+    getCollectionFiltersDocument,
+    updateCollectionDocument,
+} from './collections.graphql.js';
+import { CollectionContentsTable } from './components/collection-contents-table.js';
+import { Textarea } from '@/components/ui/textarea.js';
+import { EntityAssets } from '@/components/shared/entity-assets.js';
+import { api } from '@/graphql/api.js';
+import { useQuery } from '@tanstack/react-query';
+import { CollectionFiltersSelect } from './components/collection-filters-select.js';
+
+export const Route = createFileRoute('/_authenticated/_collections/collections_/$id')({
+    component: CollectionDetailPage,
+    loader: async ({ context, params }) => {
+        const isNew = params.id === NEW_ENTITY_PATH;
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(addCustomFields(collectionDetailDocument), { id: params.id }),
+                  { id: params.id },
+              );
+        if (!isNew && !result.collection) {
+            throw new Error(`Collection with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: [
+                { path: '/collections', label: 'Collections' },
+                isNew ? <Trans>New collection</Trans> : result.collection.name,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+export function CollectionDetailPage() {
+    const params = Route.useParams();
+    const navigate = useNavigate();
+    const creatingNewEntity = params.id === NEW_ENTITY_PATH;
+    const { i18n } = useLingui();
+
+    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
+        queryDocument: addCustomFields(collectionDetailDocument),
+        entityField: 'collection',
+        createDocument: createCollectionDocument,
+        updateDocument: updateCollectionDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                isPrivate: entity.isPrivate,
+                featuredAssetId: entity.featuredAsset?.id,
+                assets: entity.assets.map(asset => asset.id),
+                parentId: entity.parent?.id,
+                translations: entity.translations.map(translation => ({
+                    id: translation.id,
+                    languageCode: translation.languageCode,
+                    name: translation.name,
+                    slug: translation.slug,
+                    description: translation.description,
+                })),
+                filters: entity.filters.map(f => ({
+                    code: f.code,
+                    arguments: f.args.map(a => ({ name: a.name, value: a.value })),
+                })),
+                inheritFilters: entity.inheritFilters,
+                customFields: entity.customFields,
+            };
+        },
+        transformCreateInput: values => {
+            return {
+                ...values,
+                values: [],
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async data => {
+            toast(i18n.t('Successfully updated collection'), {
+                position: 'top-right',
+            });
+            form.reset(form.getValues());
+            if (creatingNewEntity) {
+                await navigate({ to: `../${data?.id}`, from: Route.id });
+            }
+        },
+        onError: err => {
+            toast(i18n.t('Failed to update collection'), {
+                position: 'top-right',
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    return (
+        <Page>
+            <PageTitle>{creatingNewEntity ? <Trans>New collection</Trans> : (entity?.name ?? '')}</PageTitle>
+            <Form {...form}>
+                <form onSubmit={submitHandler} className="space-y-8">
+                    <PageActionBar>
+                        <ContentLanguageSelector />
+                        <PermissionGuard requires={['UpdateCollection', 'UpdateCatalog']}>
+                            <Button
+                                type="submit"
+                                disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
+                            >
+                                <Trans>Update</Trans>
+                            </Button>
+                        </PermissionGuard>
+                    </PageActionBar>
+                    <PageLayout>
+                        <PageBlock column="side">
+                            <FormField
+                                control={form.control}
+                                name="isPrivate"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>
+                                            <Trans>Private</Trans>
+                                        </FormLabel>
+                                        <FormControl>
+                                            <Switch checked={field.value} onCheckedChange={field.onChange} />
+                                        </FormControl>
+                                        <FormDescription>
+                                            <Trans>Private facets are not visible in the shop</Trans>
+                                        </FormDescription>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                        </PageBlock>
+                        <PageBlock column="main">
+                            <div className="md:flex w-full gap-4 mb-4">
+                                <div className="w-1/2">
+                                    <TranslatableFormField
+                                        control={form.control}
+                                        name="name"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    <Trans>Name</Trans>
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <Input placeholder="" {...field} />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </div>
+                                <div className="w-1/2">
+                                    <TranslatableFormField
+                                        control={form.control}
+                                        name="slug"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    <Trans>Slug</Trans>
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <Input placeholder="" {...field} />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </div>
+                            </div>
+                            <TranslatableFormField
+                                control={form.control}
+                                name="description"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>
+                                            <Trans>Description</Trans>
+                                        </FormLabel>
+                                        <FormControl>
+                                            <Textarea placeholder="" {...field} />
+                                        </FormControl>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                        </PageBlock>
+                        <CustomFieldsPageBlock column="main" entityType="Collection" control={form.control} />
+                        <PageBlock column="main" title={<Trans>Filters</Trans>}>
+                            <FormField
+                                control={form.control}
+                                name="inheritFilters"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>
+                                            <Trans>Inherit filters</Trans>
+                                        </FormLabel>
+                                        <FormControl>
+                                            <Switch checked={field.value} onCheckedChange={field.onChange} />
+                                        </FormControl>
+                                        <FormDescription>
+                                            <Trans>
+                                                If enabled, the filters will be inherited from the parent
+                                                collection and combined with the filters set on this
+                                                collection.
+                                            </Trans>
+                                        </FormDescription>
+                                    </FormItem>
+                                )}
+                            />
+                            <FormField
+                                control={form.control}
+                                name="filters"
+                                render={({ field }) => (
+                                    <CollectionFiltersSelect
+                                        value={field.value}
+                                        onChange={field.onChange}
+                                    />
+                                )}
+                            />
+                        </PageBlock>
+                        <PageBlock column="side">
+                            <FormItem>
+                                <FormLabel>
+                                    <Trans>Assets</Trans>
+                                </FormLabel>
+                                <FormControl>
+                                    <EntityAssets
+                                        assets={entity?.assets}
+                                        featuredAsset={entity?.featuredAsset}
+                                        compact={true}
+                                        value={form.getValues()}
+                                        onChange={value => {
+                                            form.setValue('featuredAssetId', value.featuredAssetId, {
+                                                shouldDirty: true,
+                                                shouldValidate: true,
+                                            });
+                                            form.setValue('assetIds', value.assetIds, {
+                                                shouldDirty: true,
+                                                shouldValidate: true,
+                                            });
+                                        }}
+                                    />
+                                </FormControl>
+                                <FormDescription></FormDescription>
+                                <FormMessage />
+                            </FormItem>
+                        </PageBlock>
+                        {!creatingNewEntity && (
+                            <PageBlock column="main" title={<Trans>Facet values</Trans>}>
+                                <CollectionContentsTable collectionId={entity?.id} />
+                            </PageBlock>
+                        )}
+                    </PageLayout>
+                </form>
+            </Form>
+        </Page>
+    );
+}

+ 7 - 2
packages/dashboard/src/routes/_authenticated/_collections/components/collection-contents-sheet.tsx

@@ -9,17 +9,22 @@ import {
 import { Trans } from '@lingui/react/macro';
 import { PanelLeftOpen } from 'lucide-react';
 import { CollectionContentsTable } from './collection-contents-table.js';
+import { Button } from '@/components/ui/button.js';
 
 export interface CollectionContentsSheetProps {
     collectionId: string;
     collectionName: string;
+    children?: React.ReactNode;
 }
 
-export function CollectionContentsSheet({ collectionId, collectionName }: CollectionContentsSheetProps) {
+export function CollectionContentsSheet({ collectionId, collectionName, children }: CollectionContentsSheetProps) {
     return (
         <Sheet>
             <SheetTrigger>
-                <PanelLeftOpen className="w-4 h-4" />
+                <Button variant="outline" size="sm" className="flex items-center gap-2">
+                    {children}
+                    <PanelLeftOpen className="w-4 h-4" />
+                </Button>
             </SheetTrigger>
             <SheetContent className="min-w-[90vw] lg:min-w-[800px]">
                 <SheetHeader>

+ 80 - 0
packages/dashboard/src/routes/_authenticated/_collections/components/collection-filters-select.tsx

@@ -0,0 +1,80 @@
+import { Button } from '@/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import { api } from '@/graphql/api.js';
+import { ConfigurableOperationDefFragment, ConfigurableOperationFragment } from '@/graphql/fragments.js';
+import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
+import { Trans } from '@lingui/react/macro';
+import { useQuery } from '@tanstack/react-query';
+import { Plus } from 'lucide-react';
+import { getCollectionFiltersDocument } from '../collections.graphql.js';
+import { useServerConfig } from '@/hooks/use-server-config.js';
+import { ConfigurableOperationInput } from '@/components/shared/configurable-operation-input.js';
+
+export interface CollectionFiltersSelectProps {
+    value: ConfigurableOperationInputType[];
+    onChange: (filters: ConfigurableOperationInputType[]) => void;
+}
+
+export function CollectionFiltersSelect({ value, onChange }: CollectionFiltersSelectProps) {
+    const serverConfig = useServerConfig();
+    const { data: filtersData } = useQuery({
+        queryKey: ['collectionFilters'],
+        queryFn: () => api.query(getCollectionFiltersDocument),
+    });
+
+    const filters = filtersData?.collectionFilters;
+
+    const onFilterSelected = (filter: ConfigurableOperationDefFragment) => {
+        if (value.find(f => f.code === filter.code)) {
+            return;
+        }
+        onChange([...value, { code: filter.code, arguments: [] }]);
+    };
+
+    const onOperationValueChange = (
+        filter: ConfigurableOperationInputType,
+        newVal: ConfigurableOperationInputType,
+    ) => {
+        onChange(value.map(f => (f.code === filter.code ? newVal : f)));
+    };
+
+    return (
+        <div className="flex flex-col gap-2">
+            {(value ?? []).map((filter, index) => {
+                const filterDef = filters?.find(f => f.code === filter.code);
+                if (!filterDef) {
+                    return null;
+                }
+                return (
+                    <div key={index} className="flex flex-col gap-2">
+                        <ConfigurableOperationInput
+                            operationDefinition={filterDef}
+                            value={filter}
+                            onChange={value => onOperationValueChange(filter, value)}
+                        />
+                    </div>
+                );
+            })}
+            <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                    <Button variant="outline">
+                        <Plus />
+                        <Trans context="Add new collection filter">Add condition</Trans>
+                    </Button>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent className="w-96">
+                    {filters?.map(filter => (
+                        <DropdownMenuItem key={filter.code} onClick={() => onFilterSelected?.(filter)}>
+                            {filter.description}
+                        </DropdownMenuItem>
+                    ))}
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
+    );
+}

+ 8 - 3
packages/dashboard/src/routes/_authenticated/_facets/components/facet-values-sheet.tsx

@@ -4,22 +4,27 @@ import {
     SheetDescription,
     SheetHeader,
     SheetTitle,
-    SheetTrigger
+    SheetTrigger,
 } from '@/components/ui/sheet.js';
 import { Trans } from '@lingui/react/macro';
 import { PanelLeftOpen } from 'lucide-react';
 import { FacetValuesTable } from './facet-values-table.js';
+import { Button } from '@/components/ui/button.js';
 
 export interface FacetValuesSheetProps {
     facetName: string;
     facetId: string;
+    children?: React.ReactNode;
 }
 
-export function FacetValuesSheet({ facetName, facetId }: FacetValuesSheetProps) {
+export function FacetValuesSheet({ facetName, facetId, children }: FacetValuesSheetProps) {
     return (
         <Sheet>
             <SheetTrigger>
-                <PanelLeftOpen className="w-4 h-4" />
+                <Button variant="outline" size="sm" className="flex items-center gap-2">
+                    {children}
+                    <PanelLeftOpen className="w-4 h-4" />
+                </Button>
             </SheetTrigger>
             <SheetContent className="min-w-[90vw] lg:min-w-[800px]">
                 <SheetHeader>

+ 8 - 6
packages/dashboard/src/routes/_authenticated/_facets/facets.tsx

@@ -53,14 +53,16 @@ export function FacetListPage() {
                                         />
                                     );
                                 })}
-                                <Badge variant="outline">
-                                    {list.totalItems > 3 && (
+                                <FacetValuesSheet
+                                    facetId={cell.row.original.id}
+                                    facetName={cell.row.original.name}
+                                >
+                                    {list.totalItems > 3 ? (
                                         <div>
                                             <Trans>+ {list.totalItems - 3} more</Trans>
-                                        </div>
-                                    )}
-                                    <FacetValuesSheet facetId={cell.row.original.id} facetName={cell.row.original.name} />
-                                </Badge>
+                                        </div>) : <Trans>View values</Trans>
+                                    }
+                                </FacetValuesSheet>
                             </div>
                         );
                     },