Browse Source

feat(dashboard): Implement facet detail page

Michael Bromley 10 months ago
parent
commit
b999643906

+ 75 - 27
packages/dashboard/src/components/shared/paginated-list-data-table.tsx

@@ -9,6 +9,7 @@ import {
 import { useListQueryFields } from '@/framework/document-introspection/hooks.js';
 import { api } from '@/graphql/api.js';
 import { useDebounce } from 'use-debounce';
+import { useQueryClient } from '@tanstack/react-query';
 
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { useQuery } from '@tanstack/react-query';
@@ -54,6 +55,36 @@ export type ListQueryOptionsShape = {
     };
 };
 
+export interface PaginatedListContext {
+    refetchPaginatedList: () => void;
+}
+
+export const PaginatedListContext = React.createContext<PaginatedListContext | undefined>(undefined);
+
+/**
+ * @description
+ * Returns the context for the paginated list data table. Must be used within a PaginatedListDataTable.
+ * 
+ * @example
+ * ```ts
+ * const { refetchPaginatedList } = usePaginatedList();
+ * 
+ * const mutation = useMutation({
+ *     mutationFn: api.mutate(updateFacetValueDocument),
+ *     onSuccess: () => {
+ *         refetchPaginatedList();
+ *     },
+ * });
+ * ```
+ */
+export function usePaginatedList() {
+    const context = React.useContext(PaginatedListContext);
+    if (!context) {
+        throw new Error('usePaginatedList must be used within a PaginatedListDataTable');
+    }
+    return context;
+} 
+
 export interface PaginatedListDataTableProps<
     T extends TypedDocumentNode<U, V>,
     U extends ListQueryShape,
@@ -62,6 +93,7 @@ export interface PaginatedListDataTableProps<
     listQuery: T;
     transformVariables?: (variables: V) => V;
     customizeColumns?: CustomizeColumnConfig<T>;
+    additionalColumns?: ColumnDef<any>[];
     defaultVisibility?: Partial<Record<keyof ListQueryFields<T>, boolean>>;
     onSearchTermChange?: (searchTerm: string) => NonNullable<V['options']>['filter'];
     page: number;
@@ -81,6 +113,7 @@ export function PaginatedListDataTable<
     listQuery,
     transformVariables,
     customizeColumns,
+    additionalColumns,
     defaultVisibility,
     onSearchTermChange,
     page,
@@ -94,6 +127,7 @@ export function PaginatedListDataTable<
     const { getComponent } = useComponentRegistry();
     const [searchTerm, setSearchTerm] = React.useState<string>('');
     const [debouncedSearchTerm] = useDebounce(searchTerm, 500);
+    const queryClient = useQueryClient();
 
     const sort = sorting?.reduce((acc: any, sort: ColumnSort) => {
         const direction = sort.desc ? 'DESC' : 'ASC';
@@ -109,6 +143,19 @@ export function PaginatedListDataTable<
         ? { _and: columnFilters.map(f => ({ [f.id]: f.value })) }
         : undefined;
 
+    const queryKey = [
+        'PaginatedListDataTable',
+        listQuery,
+        page,
+        itemsPerPage,
+        sorting,
+        filter,
+    ];
+
+    function refetchPaginatedList() {
+        queryClient.invalidateQueries({ queryKey });
+    }
+
     const { data } = useQuery({
         queryFn: () => {
             const searchFilter = onSearchTermChange ? onSearchTermChange(debouncedSearchTerm) : {};
@@ -125,15 +172,7 @@ export function PaginatedListDataTable<
             const transformedVariables = transformVariables ? transformVariables(variables) : variables;
             return api.query(listQuery, transformedVariables);
         },
-        queryKey: [
-            'PaginatedListDataTable',
-            listQuery,
-            page,
-            itemsPerPage,
-            sorting,
-            filter,
-            debouncedSearchTerm,
-        ],
+        queryKey,
     });
 
     const fields = useListQueryFields(listQuery);
@@ -148,7 +187,7 @@ export function PaginatedListDataTable<
                 .filter(field => field.name !== 'customFields' && !field.type.endsWith('CustomFields'))
                 .map(field => ({ fieldInfo: field, isCustomField: false })),
         );
-        
+
         const customFieldColumn = fields.find(field => field.name === 'customFields');
         if (customFieldColumn && customFieldColumn.type !== 'JSON') {
             const customFieldFields = getTypeFieldInfo(customFieldColumn.type);
@@ -157,7 +196,7 @@ export function PaginatedListDataTable<
             );
         }
 
-        return columnConfigs.map(({ fieldInfo, isCustomField }) => {
+        const queryBasedColumns = columnConfigs.map(({ fieldInfo, isCustomField }) => {
             const customConfig = customizeColumns?.[fieldInfo.name as keyof ListQueryFields<T>] ?? {};
             const { header, ...customConfigRest } = customConfig;
             return columnHelper.accessor(fieldInfo.name as any, {
@@ -165,11 +204,16 @@ export function PaginatedListDataTable<
                 enableColumnFilter: fieldInfo.isScalar,
                 enableSorting: fieldInfo.isScalar,
                 cell: ({ cell, row }) => {
-                    const value = !isCustomField ? cell.getValue() : (row.original as any)?.customFields?.[fieldInfo.name];
+                    const value = !isCustomField
+                        ? cell.getValue()
+                        : (row.original as any)?.customFields?.[fieldInfo.name];
                     if (fieldInfo.list && Array.isArray(value)) {
                         return value.join(', ');
                     }
-                    if ((fieldInfo.type === 'DateTime' && typeof value === 'string') || value instanceof Date) {
+                    if (
+                        (fieldInfo.type === 'DateTime' && typeof value === 'string') ||
+                        value instanceof Date
+                    ) {
                         return <Delegate component="dateTime.display" value={value} />;
                     }
                     if (fieldInfo.type === 'Boolean') {
@@ -191,25 +235,29 @@ export function PaginatedListDataTable<
                 ...customConfigRest,
             });
         });
+
+        return [...queryBasedColumns, ...(additionalColumns?.map(def => columnHelper.accessor(def.id, def)) ?? [])];
     }, [fields, customizeColumns]);
 
     const columnVisibility = getColumnVisibility(fields, defaultVisibility);
 
     return (
-        <DataTable
-            columns={columns}
-            data={(data as any)?.[queryName]?.items ?? []}
-            page={page}
-            itemsPerPage={itemsPerPage}
-            sorting={sorting}
-            columnFilters={columnFilters}
-            totalItems={(data as any)?.[queryName]?.totalItems ?? 0}
-            onPageChange={onPageChange}
-            onSortChange={onSortChange}
-            onFilterChange={onFilterChange}
-            onSearchTermChange={onSearchTermChange ? term => setSearchTerm(term) : undefined}
-            defaultColumnVisibility={columnVisibility}
-        />
+        <PaginatedListContext.Provider value={{ refetchPaginatedList }}>
+            <DataTable
+                columns={columns}
+                data={(data as any)?.[queryName]?.items ?? []}
+                page={page}
+                itemsPerPage={itemsPerPage}
+                sorting={sorting}
+                columnFilters={columnFilters}
+                totalItems={(data as any)?.[queryName]?.totalItems ?? 0}
+                onPageChange={onPageChange}
+                onSortChange={onSortChange}
+                onFilterChange={onFilterChange}
+                onSearchTermChange={onSearchTermChange ? term => setSearchTerm(term) : undefined}
+                defaultColumnVisibility={columnVisibility}
+            />
+        </PaginatedListContext.Provider>
     );
 }
 

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

@@ -21,6 +21,7 @@ import { Route as AuthenticatedProductVariantsProductVariantsImport } from './ro
 import { Route as AuthenticatedFacetsFacetsImport } from './routes/_authenticated/_facets/facets';
 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';
 
 // Create/Update Routes
 
@@ -85,6 +86,12 @@ const AuthenticatedProductVariantsProductVariantsIdRoute =
         getParentRoute: () => AuthenticatedRoute,
     } as any);
 
+const AuthenticatedFacetsFacetsIdRoute = AuthenticatedFacetsFacetsIdImport.update({
+    id: '/_facets/facets_/$id',
+    path: '/facets/$id',
+    getParentRoute: () => AuthenticatedRoute,
+} as any);
+
 // Populate the FileRoutesByPath interface
 
 declare module '@tanstack/react-router' {
@@ -145,6 +152,13 @@ declare module '@tanstack/react-router' {
             preLoaderRoute: typeof AuthenticatedProductsProductsImport;
             parentRoute: typeof AuthenticatedImport;
         };
+        '/_authenticated/_facets/facets_/$id': {
+            id: '/_authenticated/_facets/facets_/$id';
+            path: '/facets/$id';
+            fullPath: '/facets/$id';
+            preLoaderRoute: typeof AuthenticatedFacetsFacetsIdImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
         '/_authenticated/_product-variants/product-variants_/$id': {
             id: '/_authenticated/_product-variants/product-variants_/$id';
             path: '/product-variants/$id';
@@ -170,6 +184,7 @@ interface AuthenticatedRouteChildren {
     AuthenticatedFacetsFacetsRoute: typeof AuthenticatedFacetsFacetsRoute;
     AuthenticatedProductVariantsProductVariantsRoute: typeof AuthenticatedProductVariantsProductVariantsRoute;
     AuthenticatedProductsProductsRoute: typeof AuthenticatedProductsProductsRoute;
+    AuthenticatedFacetsFacetsIdRoute: typeof AuthenticatedFacetsFacetsIdRoute;
     AuthenticatedProductVariantsProductVariantsIdRoute: typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     AuthenticatedProductsProductsIdRoute: typeof AuthenticatedProductsProductsIdRoute;
 }
@@ -180,6 +195,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
     AuthenticatedFacetsFacetsRoute: AuthenticatedFacetsFacetsRoute,
     AuthenticatedProductVariantsProductVariantsRoute: AuthenticatedProductVariantsProductVariantsRoute,
     AuthenticatedProductsProductsRoute: AuthenticatedProductsProductsRoute,
+    AuthenticatedFacetsFacetsIdRoute: AuthenticatedFacetsFacetsIdRoute,
     AuthenticatedProductVariantsProductVariantsIdRoute: AuthenticatedProductVariantsProductVariantsIdRoute,
     AuthenticatedProductsProductsIdRoute: AuthenticatedProductsProductsIdRoute,
 };
@@ -195,6 +211,7 @@ export interface FileRoutesByFullPath {
     '/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
+    '/facets/$id': typeof AuthenticatedFacetsFacetsIdRoute;
     '/product-variants/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
@@ -207,6 +224,7 @@ export interface FileRoutesByTo {
     '/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
+    '/facets/$id': typeof AuthenticatedFacetsFacetsIdRoute;
     '/product-variants/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
@@ -221,6 +239,7 @@ export interface FileRoutesById {
     '/_authenticated/_facets/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/_authenticated/_product-variants/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/_authenticated/_products/products': typeof AuthenticatedProductsProductsRoute;
+    '/_authenticated/_facets/facets_/$id': typeof AuthenticatedFacetsFacetsIdRoute;
     '/_authenticated/_product-variants/product-variants_/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     '/_authenticated/_products/products_/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
@@ -236,6 +255,7 @@ export interface FileRouteTypes {
         | '/facets'
         | '/product-variants'
         | '/products'
+        | '/facets/$id'
         | '/product-variants/$id'
         | '/products/$id';
     fileRoutesByTo: FileRoutesByTo;
@@ -247,6 +267,7 @@ export interface FileRouteTypes {
         | '/facets'
         | '/product-variants'
         | '/products'
+        | '/facets/$id'
         | '/product-variants/$id'
         | '/products/$id';
     id:
@@ -259,6 +280,7 @@ export interface FileRouteTypes {
         | '/_authenticated/_facets/facets'
         | '/_authenticated/_product-variants/product-variants'
         | '/_authenticated/_products/products'
+        | '/_authenticated/_facets/facets_/$id'
         | '/_authenticated/_product-variants/product-variants_/$id'
         | '/_authenticated/_products/products_/$id';
     fileRoutesById: FileRoutesById;
@@ -297,6 +319,7 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
         "/_authenticated/_facets/facets",
         "/_authenticated/_product-variants/product-variants",
         "/_authenticated/_products/products",
+        "/_authenticated/_facets/facets_/$id",
         "/_authenticated/_product-variants/product-variants_/$id",
         "/_authenticated/_products/products_/$id"
       ]
@@ -327,6 +350,10 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "filePath": "_authenticated/_products/products.tsx",
       "parent": "/_authenticated"
     },
+    "/_authenticated/_facets/facets_/$id": {
+      "filePath": "_authenticated/_facets/facets_.$id.tsx",
+      "parent": "/_authenticated"
+    },
     "/_authenticated/_product-variants/product-variants_/$id": {
       "filePath": "_authenticated/_product-variants/product-variants_.$id.tsx",
       "parent": "/_authenticated"

+ 127 - 0
packages/dashboard/src/routes/_authenticated/_facets/components/edit-facet-value.tsx

@@ -0,0 +1,127 @@
+import { usePaginatedList } from '@/components/shared/paginated-list-data-table.js';
+import { Button } from '@/components/ui/button.js';
+import { Form, FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form.js';
+import { Input } from '@/components/ui/input.js';
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { useUserSettings } from '@/hooks/use-user-settings.js';
+import { Trans } from '@lingui/react/macro';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { useForm } from 'react-hook-form';
+
+const facetValuesDocument = graphql(`
+    query FacetValue($options: FacetValueListOptions) {
+        facetValues(options: $options) {
+            items {
+                id
+                name
+                code
+                customFields
+                translations {
+                    id
+                    languageCode
+                    name
+                }
+            }
+        }
+    }
+`);
+
+const updateFacetValueDocument = graphql(`
+    mutation UpdateFacetValue($input: [UpdateFacetValueInput!]!) {
+        updateFacetValues(input: $input) {
+            id
+        }
+    }
+`);
+
+export interface EditFacetValueProps {
+    facetValueId: string;
+}
+
+export function EditFacetValue({ facetValueId }: EditFacetValueProps) {
+    const {
+        settings: { contentLanguage },
+    } = useUserSettings();
+    const { refetchPaginatedList } = usePaginatedList();
+    const { data: facetValues } = useQuery({
+        queryKey: ['facetValues', facetValueId],
+        queryFn: () => api.query(facetValuesDocument, { options: { filter: { id: { eq: facetValueId } } } }),
+    });
+    const { mutate: updateFacetValue } = useMutation({
+        mutationFn: api.mutate(updateFacetValueDocument),
+        onSuccess: () => {
+            refetchPaginatedList();
+        },
+    });
+    const facetValue = facetValues?.facetValues.items[0];
+
+    const form = useForm({
+        values: {
+            name: facetValue?.name ?? '',
+            code: facetValue?.code ?? '',
+        },
+    });
+
+    if (!facetValue) {
+        return <div>Facet value not found</div>;
+    }
+
+    const handleSave = (values: { name: string; code: string }) => {
+        const translations = facetValue.translations.map(translation => {
+            if (translation.languageCode === contentLanguage) {
+                return {
+                    id: translation.id,
+                    languageCode: translation.languageCode,
+                    name: values.name,
+                };
+            }
+            return translation;
+        });
+        updateFacetValue({
+            input: [
+                {
+                    id: facetValue.id,
+                    translations,
+                    code: values.code,
+                },
+            ],
+        });
+    };
+
+    return (
+        <div className="grid gap-4">
+            <div className="space-y-2">
+                <h4 className="font-medium leading-none">Edit Facet Value</h4>
+                <p className="text-sm text-muted-foreground">Update the name and code of this facet value.</p>
+            </div>
+            <Form {...form}>
+                <form onSubmit={form.handleSubmit(handleSave)} className="grid gap-2">
+                    <FormItem>
+                        <FormLabel>
+                            <Trans>Name</Trans>
+                        </FormLabel>
+                        <FormControl>
+                            <Input placeholder="" {...form.register('name')} />
+                        </FormControl>
+                        <FormMessage />
+                    </FormItem>
+                    <FormItem>
+                        <FormLabel>
+                            <Trans>Code</Trans>
+                        </FormLabel>
+                        <FormControl>
+                            <Input placeholder="" {...form.register('code')} />
+                        </FormControl>
+                        <FormMessage />
+                    </FormItem>
+                    <div className="flex justify-end">
+                        <Button type="submit" size="sm">
+                            <Trans>Save changes</Trans>
+                        </Button>
+                    </div>
+                </form>
+            </Form>
+        </div>
+    );
+}

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

@@ -1,34 +1,13 @@
-import { PaginatedListDataTable } from '@/components/shared/paginated-list-data-table.js';
 import {
     Sheet,
     SheetContent,
-    SheetDescription,
     SheetHeader,
     SheetTitle,
-    SheetTrigger,
+    SheetTrigger
 } from '@/components/ui/sheet.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { graphql } from '@/graphql/graphql.js';
 import { Trans } from '@lingui/react/macro';
-import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
 import { PanelLeftOpen } from 'lucide-react';
-import { useState } from 'react';
-
-const facetValueListDocument = graphql(`
-    query FacetValueList($options: FacetValueListOptions) {
-        facetValues(options: $options) {
-            items {
-                id
-                createdAt
-                updatedAt
-                name
-                code
-                customFields
-            }
-            totalItems
-        }
-    }
-`);
+import { FacetValuesTable } from './facet-values-table.js';
 
 export interface FacetValuesSheetProps {
     facetName: string;
@@ -36,10 +15,6 @@ export interface FacetValuesSheetProps {
 }
 
 export function FacetValuesSheet({ facetName, facetId }: FacetValuesSheetProps) {
-    const [sorting, setSorting] = useState<SortingState>([]);
-    const [page, setPage] = useState(1);
-    const [pageSize, setPageSize] = useState(10);
-    const [filters, setFilters] = useState<ColumnFiltersState>([]);
     return (
         <Sheet>
             <SheetTrigger>
@@ -50,46 +25,7 @@ export function FacetValuesSheet({ facetName, facetId }: FacetValuesSheetProps)
                     <SheetTitle>
                         <Trans>Facet values for {facetName}</Trans>
                     </SheetTitle>
-                    <SheetDescription>
-                        <PaginatedListDataTable
-                            listQuery={addCustomFields(facetValueListDocument)}
-                            page={page}
-                            itemsPerPage={pageSize}
-                            sorting={sorting}
-                            columnFilters={filters}
-                            onPageChange={(_, page, perPage) => {
-                                setPage(page);
-                                setPageSize(perPage);
-                            }}
-                            onSortChange={(_, sorting) => {
-                                setSorting(sorting);
-                            }}
-                            onFilterChange={(_, filters) => {
-                                setFilters(filters);
-                            }}
-                            transformVariables={variables => {
-                                const filter = variables.options?.filter ?? {};
-                                return {
-                                    options: {
-                                        filter: {
-                                            ...filter,
-                                            facetId: { eq: facetId },
-                                        },
-                                        sort: variables.options?.sort,
-                                        take: pageSize,
-                                        skip: (page - 1) * pageSize,
-                                    },
-                                };
-                            }}
-                            onSearchTermChange={searchTerm => {
-                                return {
-                                    name: {
-                                        contains: searchTerm,
-                                    },
-                                };
-                            }}
-                        />
-                    </SheetDescription>
+                        <FacetValuesTable facetId={facetId} />
                 </SheetHeader>
             </SheetContent>
         </Sheet>

+ 97 - 0
packages/dashboard/src/routes/_authenticated/_facets/components/facet-values-table.tsx

@@ -0,0 +1,97 @@
+import { PaginatedListDataTable } from '@/components/shared/paginated-list-data-table.js';
+import { Button } from '@/components/ui/button.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import { graphql } from '@/graphql/graphql.js';
+import { Trans } from '@lingui/react/macro';
+import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
+import { useState } from 'react';
+import { EditFacetValue } from './edit-facet-value.js';
+
+export const facetValueListDocument = graphql(`
+    query FacetValueList($options: FacetValueListOptions) {
+        facetValues(options: $options) {
+            items {
+                id
+                createdAt
+                updatedAt
+                name
+                code
+                customFields
+            }
+            totalItems
+        }
+    }
+`);
+
+
+export interface FacetValuesTableProps {
+    facetId: string;
+}
+
+export function FacetValuesTable({ facetId }: FacetValuesTableProps) {
+    const [sorting, setSorting] = useState<SortingState>([]);
+    const [page, setPage] = useState(1);
+    const [pageSize, setPageSize] = useState(10);
+    const [filters, setFilters] = useState<ColumnFiltersState>([]);
+    
+    return (
+        <PaginatedListDataTable
+            listQuery={addCustomFields(facetValueListDocument)}
+            page={page}
+            itemsPerPage={pageSize}
+            sorting={sorting}
+            columnFilters={filters}
+            onPageChange={(_, page, perPage) => {
+                setPage(page);
+                setPageSize(perPage);
+            }}
+            onSortChange={(_, sorting) => {
+                setSorting(sorting);
+            }}
+            onFilterChange={(_, filters) => {
+                setFilters(filters);
+            }}
+            transformVariables={variables => {
+                const filter = variables.options?.filter ?? {};
+                return {
+                    options: {
+                        filter: {
+                            ...filter,
+                            facetId: { eq: facetId },
+                        },
+                        sort: variables.options?.sort,
+                        take: pageSize,
+                        skip: (page - 1) * pageSize,
+                    },
+                };
+            }}
+            onSearchTermChange={searchTerm => {
+                return {
+                    name: {
+                        contains: searchTerm,
+                    },
+                };
+            }}
+            additionalColumns={[
+                {
+                    id: 'actions',
+                    header: 'Actions',
+                    cell: ({ row }) => {
+                        const facetValue = row.original;
+                        return (
+                            <Popover>
+                                <PopoverTrigger asChild>
+                                    <Button type="button" variant="outline" size="sm"><Trans>Edit</Trans></Button>
+                                </PopoverTrigger>
+                                <PopoverContent className="w-80">
+                                    <EditFacetValue facetValueId={facetValue.id} />
+                                </PopoverContent>
+                            </Popover>
+                        );
+                    },
+                },
+            ]}
+        />
+    );
+}

+ 36 - 0
packages/dashboard/src/routes/_authenticated/_facets/facets.graphql.ts

@@ -57,3 +57,39 @@ export const facetListDocument = graphql(
     `,
     [facetWithValuesFragment],
 );
+
+export const facetDetailDocument = graphql(`
+    query FacetDetail($id: ID!) {
+        facet(id: $id) {
+            id
+            createdAt
+            updatedAt
+            name
+            code
+            languageCode
+            isPrivate
+            translations {
+                id
+                languageCode
+                name
+            }
+            customFields
+        }
+    }
+`);
+
+export const createFacetDocument = graphql(`
+    mutation CreateFacet($input: CreateFacetInput!) {
+        createFacet(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updateFacetDocument = graphql(`
+    mutation UpdateFacet($input: UpdateFacetInput!) {
+        updateFacet(input: $input) {
+            id
+        }
+    }
+`);

+ 190 - 0
packages/dashboard/src/routes/_authenticated/_facets/facets_.$id.tsx

@@ -0,0 +1,190 @@
+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 { FacetValuesTable } from './components/facet-values-table.js';
+import { createFacetDocument, facetDetailDocument, updateFacetDocument } from './facets.graphql.js';
+
+export const Route = createFileRoute('/_authenticated/_facets/facets_/$id')({
+    component: FacetDetailPage,
+    loader: async ({ context, params }) => {
+        const isNew = params.id === NEW_ENTITY_PATH;
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(addCustomFields(facetDetailDocument), { id: params.id }),
+                  { id: params.id },
+              );
+        if (!isNew && !result.facet) {
+            throw new Error(`Facet with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: [
+                { path: '/facets', label: 'Facets' },
+                isNew ? <Trans>New facet</Trans> : result.facet.name,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+export function FacetDetailPage() {
+    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(facetDetailDocument),
+        entityField: 'facet',
+        createDocument: createFacetDocument,
+        updateDocument: updateFacetDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                isPrivate: entity.isPrivate,
+                code: entity.code,
+                translations: entity.translations.map(translation => ({
+                    id: translation.id,
+                    languageCode: translation.languageCode,
+                    name: translation.name,
+                    customFields: translation.customFields,
+                })),
+                customFields: entity.customFields,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: data => {
+            toast(i18n.t('Successfully updated facet'), {
+                position: 'top-right',
+            });
+            form.reset(form.getValues());
+            if (creatingNewEntity) {
+                navigate({ to: `../${data?.[0]?.id}`, from: Route.id });
+            }
+        },
+        onError: err => {
+            toast(i18n.t('Failed to update facet'), {
+                position: 'top-right',
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    const [price, taxCategoryId] = form.watch(['price', 'taxCategoryId']);
+
+    return (
+        <Page>
+            <PageTitle>{creatingNewEntity ? <Trans>New facet</Trans> : (entity?.name ?? '')}</PageTitle>
+            <Form {...form}>
+                <form onSubmit={submitHandler} className="space-y-8">
+                    <PageActionBar>
+                        <ContentLanguageSelector />
+                        <PermissionGuard requires={['UpdateProduct', '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">
+                                <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">
+                                    <FormField
+                                        control={form.control}
+                                        name="code"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    <Trans>Code</Trans>
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <Input placeholder="" {...field} />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </div>
+                            </div>
+                        </PageBlock>
+                        <CustomFieldsPageBlock
+                            column="main"
+                            entityType="Facet"
+                            control={form.control}
+                        />
+                        <PageBlock column="main" title={<Trans>Facet values</Trans>}>
+                            <FacetValuesTable facetId={entity?.id} />
+                        </PageBlock>
+                    </PageLayout>
+                </form>
+            </Form>
+        </Page>
+    );
+}