Browse Source

refactor(dashboard): Simplify detail page markup

Michael Bromley 10 months ago
parent
commit
0cd62e7e87
66 changed files with 2318 additions and 3136 deletions
  1. 108 112
      package-lock.json
  2. 1 0
      packages/dashboard/package.json
  3. 9 9
      packages/dashboard/src/components/data-input/datetime-input.tsx
  4. 2 2
      packages/dashboard/src/components/layout/generated-breadcrumbs.tsx
  5. 37 0
      packages/dashboard/src/components/shared/form-field-wrapper.tsx
  6. 0 3
      packages/dashboard/src/components/shared/paginated-list-data-table.tsx
  7. 1 1
      packages/dashboard/src/components/shared/tax-category-selector.tsx
  8. 35 0
      packages/dashboard/src/components/shared/translatable-form-field.tsx
  9. 1 1
      packages/dashboard/src/components/shared/zone-selector.tsx
  10. 2 2
      packages/dashboard/src/framework/document-introspection/add-custom-fields.ts
  11. 75 12
      packages/dashboard/src/framework/layout-engine/page-layout.tsx
  12. 48 0
      packages/dashboard/src/framework/page/detail-page-route-loader.tsx
  13. 6 6
      packages/dashboard/src/framework/page/list-page.tsx
  14. 51 0
      packages/dashboard/src/framework/page/page-types.ts
  15. 27 8
      packages/dashboard/src/framework/page/use-detail-page.ts
  16. 16 15
      packages/dashboard/src/routes/_authenticated/_administrators/administrators.tsx
  17. 51 102
      packages/dashboard/src/routes/_authenticated/_administrators/administrators_.$id.tsx
  18. 4 4
      packages/dashboard/src/routes/_authenticated/_assets/assets.tsx
  19. 24 18
      packages/dashboard/src/routes/_authenticated/_channels/channels.tsx
  20. 149 243
      packages/dashboard/src/routes/_authenticated/_channels/channels_.$id.tsx
  21. 1 0
      packages/dashboard/src/routes/_authenticated/_collections/collections.graphql.ts
  22. 15 15
      packages/dashboard/src/routes/_authenticated/_collections/collections.tsx
  23. 124 179
      packages/dashboard/src/routes/_authenticated/_collections/collections_.$id.tsx
  24. 13 13
      packages/dashboard/src/routes/_authenticated/_countries/countries.tsx
  25. 52 97
      packages/dashboard/src/routes/_authenticated/_countries/countries_.$id.tsx
  26. 1 0
      packages/dashboard/src/routes/_authenticated/_customer-groups/customer-groups.graphql.ts
  27. 28 27
      packages/dashboard/src/routes/_authenticated/_customer-groups/customer-groups.tsx
  28. 12 145
      packages/dashboard/src/routes/_authenticated/_customer-groups/customer-groups_.$id.tsx
  29. 1 0
      packages/dashboard/src/routes/_authenticated/_customers/customers.graphql.ts
  30. 22 5
      packages/dashboard/src/routes/_authenticated/_customers/customers.tsx
  31. 169 214
      packages/dashboard/src/routes/_authenticated/_customers/customers_.$id.tsx
  32. 10 10
      packages/dashboard/src/routes/_authenticated/_facets/facets.tsx
  33. 57 108
      packages/dashboard/src/routes/_authenticated/_facets/facets_.$id.tsx
  34. 70 103
      packages/dashboard/src/routes/_authenticated/_global-settings/global-settings.tsx
  35. 1 0
      packages/dashboard/src/routes/_authenticated/_orders/orders.graphql.ts
  36. 5 9
      packages/dashboard/src/routes/_authenticated/_orders/orders.tsx
  37. 57 67
      packages/dashboard/src/routes/_authenticated/_orders/orders_.$id.tsx
  38. 3 3
      packages/dashboard/src/routes/_authenticated/_payment-methods/components/payment-eligibility-checker-selector.tsx
  39. 3 3
      packages/dashboard/src/routes/_authenticated/_payment-methods/components/payment-handler-selector.tsx
  40. 1 2
      packages/dashboard/src/routes/_authenticated/_payment-methods/payment-methods.tsx
  41. 44 95
      packages/dashboard/src/routes/_authenticated/_payment-methods/payment-methods_.$id.tsx
  42. 2 2
      packages/dashboard/src/routes/_authenticated/_product-variants/components/variant-price-detail.tsx
  43. 2 0
      packages/dashboard/src/routes/_authenticated/_product-variants/product-variants.graphql.ts
  44. 196 266
      packages/dashboard/src/routes/_authenticated/_product-variants/product-variants_.$id.tsx
  45. 16 16
      packages/dashboard/src/routes/_authenticated/_products/products.tsx
  46. 110 180
      packages/dashboard/src/routes/_authenticated/_products/products_.$id.tsx
  47. 41 87
      packages/dashboard/src/routes/_authenticated/_profile/profile.tsx
  48. 9 8
      packages/dashboard/src/routes/_authenticated/_promotions/promotions.tsx
  49. 120 209
      packages/dashboard/src/routes/_authenticated/_promotions/promotions_.$id.tsx
  50. 9 8
      packages/dashboard/src/routes/_authenticated/_roles/roles.tsx
  51. 72 124
      packages/dashboard/src/routes/_authenticated/_roles/roles_.$id.tsx
  52. 12 10
      packages/dashboard/src/routes/_authenticated/_sellers/sellers.tsx
  53. 9 20
      packages/dashboard/src/routes/_authenticated/_sellers/sellers_.$id.tsx
  54. 5 6
      packages/dashboard/src/routes/_authenticated/_shipping-methods/components/fulfillment-handler-selector.tsx
  55. 3 3
      packages/dashboard/src/routes/_authenticated/_shipping-methods/components/shipping-calculator-selector.tsx
  56. 3 3
      packages/dashboard/src/routes/_authenticated/_shipping-methods/components/shipping-eligibility-checker-selector.tsx
  57. 19 18
      packages/dashboard/src/routes/_authenticated/_shipping-methods/shipping-methods.tsx
  58. 77 124
      packages/dashboard/src/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx
  59. 17 16
      packages/dashboard/src/routes/_authenticated/_stock-locations/stock-locations.tsx
  60. 43 76
      packages/dashboard/src/routes/_authenticated/_stock-locations/stock-locations_.$id.tsx
  61. 18 17
      packages/dashboard/src/routes/_authenticated/_tax-categories/tax-categories.tsx
  62. 43 76
      packages/dashboard/src/routes/_authenticated/_tax-categories/tax-categories_.$id.tsx
  63. 21 23
      packages/dashboard/src/routes/_authenticated/_tax-rates/tax-rates.tsx
  64. 75 121
      packages/dashboard/src/routes/_authenticated/_tax-rates/tax-rates_.$id.tsx
  65. 18 17
      packages/dashboard/src/routes/_authenticated/_zones/zones.tsx
  66. 42 73
      packages/dashboard/src/routes/_authenticated/_zones/zones_.$id.tsx

File diff suppressed because it is too large
+ 108 - 112
package-lock.json


+ 1 - 0
packages/dashboard/package.json

@@ -51,6 +51,7 @@
     "@tanstack/react-router": "^1.105.0",
     "@tanstack/react-table": "^8.21.2",
     "@types/node": "^22.13.4",
+    "@uidotdev/usehooks": "^2.4.1",
     "awesome-graphql-client": "^2.1.0",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",

+ 9 - 9
packages/dashboard/src/components/data-input/datetime-input.tsx

@@ -15,18 +15,18 @@ import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area.js";
 import { CalendarClock } from "lucide-react";
 
 export interface DateTimeInputProps {
-  value: Date;
+  value: Date | string | undefined;
   onChange: (value: Date) => void;
 }
  
 export function DateTimeInput(props: DateTimeInputProps) {
-  const [date, setDate] = React.useState<Date>(props.value);
+  const [date, setDate] = React.useState<string>(props.value && props.value instanceof Date ? props.value.toISOString() : props.value ?? '');
   const [isOpen, setIsOpen] = React.useState(false);
  
   const hours = Array.from({ length: 12 }, (_, i) => i + 1);
   const handleDateSelect = (selectedDate: Date | undefined) => {
     if (selectedDate) {
-      setDate(selectedDate);
+      setDate(selectedDate.toISOString());
       props.onChange(selectedDate);
     }
   };
@@ -49,7 +49,7 @@ export function DateTimeInput(props: DateTimeInputProps) {
           value === "PM" ? currentHours + 12 : currentHours - 12
         );
       }
-      setDate(newDate);
+      setDate(newDate.toISOString());
       props.onChange(newDate);
     }
   };
@@ -76,7 +76,7 @@ export function DateTimeInput(props: DateTimeInputProps) {
         <div className="sm:flex">
           <Calendar
             mode="single"
-            selected={date}
+            selected={new Date(date)}
             onSelect={handleDateSelect}
             initialFocus
           />
@@ -88,7 +88,7 @@ export function DateTimeInput(props: DateTimeInputProps) {
                     key={hour}
                     size="icon"
                     variant={
-                      date && date.getHours() % 12 === hour % 12
+                      date && new Date(date).getHours() % 12 === hour % 12
                         ? "default"
                         : "ghost"
                     }
@@ -108,7 +108,7 @@ export function DateTimeInput(props: DateTimeInputProps) {
                     key={minute}
                     size="icon"
                     variant={
-                      date && date.getMinutes() === minute
+                      date && new Date(date).getMinutes() === minute
                         ? "default"
                         : "ghost"
                     }
@@ -131,8 +131,8 @@ export function DateTimeInput(props: DateTimeInputProps) {
                     size="icon"
                     variant={
                       date &&
-                      ((ampm === "AM" && date.getHours() < 12) ||
-                        (ampm === "PM" && date.getHours() >= 12))
+                      ((ampm === "AM" && new Date(date).getHours() < 12) ||
+                        (ampm === "PM" && new Date(date).getHours() >= 12))
                         ? "default"
                         : "ghost"
                     }

+ 2 - 2
packages/dashboard/src/components/layout/generated-breadcrumbs.tsx

@@ -10,11 +10,11 @@ import * as React from 'react';
 import { Fragment } from 'react';
 
 export interface BreadcrumbItem {
-    label: string | React.ReactNode;
+    label: string | React.ReactElement;
     path: string;
 }
 
-export type BreadcrumbShorthand = string;
+export type BreadcrumbShorthand = string | React.ReactElement;
 
 export type PageBreadcrumb = BreadcrumbItem | BreadcrumbShorthand;
 

+ 37 - 0
packages/dashboard/src/components/shared/form-field-wrapper.tsx

@@ -0,0 +1,37 @@
+import {
+    FormControl,
+    FormDescription,
+    FormItem,
+    FormLabel,
+    FormMessage,
+    FormField,
+} from '../ui/form.js';
+import { FieldValues, FieldPath } from 'react-hook-form';
+
+export type FormFieldWrapperProps<
+    TFieldValues extends FieldValues = FieldValues,
+    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
+> = React.ComponentProps<typeof FormField<TFieldValues, TName>> & {
+    label?: React.ReactNode;
+    description?: React.ReactNode;
+};
+
+export function FormFieldWrapper<
+    TFieldValues extends FieldValues = FieldValues,
+    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
+>({ control, name, render, label, description }: FormFieldWrapperProps<TFieldValues, TName>) {
+    return (
+        <FormField
+            control={control}
+            name={name}
+            render={renderArgs => (
+                <FormItem>
+                    {label && <FormLabel>{label}</FormLabel>}
+                    <FormControl>{render(renderArgs)}</FormControl>
+                    {description && <FormDescription>{description}</FormDescription>}
+                    <FormMessage />
+                </FormItem>
+            )}
+        />
+    );
+}

+ 0 - 3
packages/dashboard/src/components/shared/paginated-list-data-table.tsx

@@ -30,9 +30,6 @@ type IsPaginatedList<T> = T extends { items: any[]; totalItems: number } ? true
 // Helper type to extract string keys from an object
 type StringKeys<T> = T extends object ? Extract<keyof T, string> : never;
 
-// Helper type to handle nullability
-type NonNullable<T> = T extends null | undefined ? never : T;
-
 // Non-recursive approach to find paginated list paths with max 2 levels
 // Level 0: Direct top-level check
 type Level0PaginatedLists<T> = T extends object ? (IsPaginatedList<T> extends true ? '' : never) : never;

+ 1 - 1
packages/dashboard/src/components/shared/tax-category-selector.tsx

@@ -25,7 +25,7 @@ const taxCategoriesDocument = graphql(`
 `);
 
 export interface TaxCategorySelectorProps {
-    value: string;
+    value: string | undefined;
     onChange: (value: string) => void;
 }
 

+ 35 - 0
packages/dashboard/src/components/shared/translatable-form-field.tsx

@@ -3,6 +3,11 @@ import { FieldPath } from 'react-hook-form';
 import { useUserSettings } from '@/hooks/use-user-settings.js';
 import { ControllerProps } from 'react-hook-form';
 import { FieldValues } from 'react-hook-form';
+import { FormFieldWrapper } from './form-field-wrapper.js';
+import { FormMessage } from '../ui/form.js';
+import { FormControl } from '../ui/form.js';
+import { FormItem } from '../ui/form.js';
+import { FormDescription, FormField, FormLabel } from '../ui/form.js';
 
 export type TranslatableEntity = FieldValues & {
     translations?: Array<{ languageCode: string }> | null;
@@ -37,3 +42,33 @@ export const TranslatableFormField = <
     const translationName = `translations.${index}.${String(name)}` as FieldPath<TFieldValues>;
     return <Controller {...props} name={translationName} key={translationName} />;
 };
+
+export type TranslatableFormFieldWrapperProps<
+    TFieldValues extends TranslatableEntity | TranslatableEntity[],
+> = TranslatableFormFieldProps<TFieldValues> &
+    Omit<React.ComponentProps<typeof FormFieldWrapper<TFieldValues>>, 'name'>;
+
+export const TranslatableFormFieldWrapper = <
+    TFieldValues extends TranslatableEntity | TranslatableEntity[] = TranslatableEntity,
+>({
+    name,
+    label,
+    description,
+    render,
+    ...props
+}: TranslatableFormFieldWrapperProps<TFieldValues>) => {
+    return (
+        <TranslatableFormField
+            control={props.control}
+            name={name}
+            render={renderArgs => (
+                <FormItem>
+                    {label && <FormLabel>{label}</FormLabel>}
+                    <FormControl>{render(renderArgs)}</FormControl>
+                    {description && <FormDescription>{description}</FormDescription>}
+                    <FormMessage />
+                </FormItem>
+            )}
+        />
+    );
+};

+ 1 - 1
packages/dashboard/src/components/shared/zone-selector.tsx

@@ -24,7 +24,7 @@ const zonesDocument = graphql(`
 `);
 
 export interface ZoneSelectorProps {
-    value: string;
+    value: string | undefined;
     onChange: (value: string) => void;
 }
 

+ 2 - 2
packages/dashboard/src/framework/document-introspection/add-custom-fields.ts

@@ -1,10 +1,11 @@
+import { Variables } from '@/graphql/api.js';
 import {
     getServerConfigDocument,
     relationCustomFieldFragment,
     structCustomFieldFragment,
 } from '@/providers/server-config.js';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
-import { CustomFieldConfig, EntityCustomFields } from '@vendure/common/lib/generated-types';
+import { CustomFieldConfig } from '@vendure/common/lib/generated-types';
 import { ResultOf } from 'gql.tada';
 import {
     DefinitionNode,
@@ -16,7 +17,6 @@ import {
     SelectionNode,
     SelectionSetNode,
 } from 'graphql';
-import type { Variables } from 'graphql-request';
 
 import { getOperationTypeInfo } from './get-document-structure.js';
 

+ 75 - 12
packages/dashboard/src/framework/layout-engine/page-layout.tsx

@@ -1,14 +1,16 @@
 import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
+import { Form } from '@/components/ui/form.js';
 import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
 import { cn } from '@/lib/utils.js';
 import React from 'react';
-import { Control } from 'react-hook-form';
+import { Control, UseFormReturn } from 'react-hook-form';
+import { useMediaQuery } from '@uidotdev/usehooks';
 
 export type PageBlockProps = {
     children: React.ReactNode;
     /** Which column this block should appear in */
-    column: 'main' | 'side' ;
+    column: 'main' | 'side';
     title?: React.ReactNode | string;
     description?: React.ReactNode | string;
     className?: string;
@@ -25,25 +27,61 @@ function isPageBlock(child: unknown): child is React.ReactElement<PageBlockProps
 }
 
 export function PageLayout({ children, className }: PageLayoutProps) {
+    const isDesktop = useMediaQuery('only screen and (min-width : 769px)');
     // Separate blocks into categories
-    const childArray = React.Children.toArray(children);
+    const childArray: React.ReactElement<PageBlockProps>[] = [];
+    React.Children.forEach(children, child => {
+        if (isPageBlock(child)) {
+            childArray.push(child);
+        }
+        // check for a React Fragment
+        if (React.isValidElement(child) && child.type === React.Fragment) {
+            React.Children.forEach((child as React.ReactElement<PageBlockProps>).props.children, child => {
+                if (isPageBlock(child)) {
+                    childArray.push(child);
+                }
+            });
+        }
+    });
     const mainBlocks = childArray.filter(child => isPageBlock(child) && child.props.column === 'main');
     const sideBlocks = childArray.filter(child => isPageBlock(child) && child.props.column === 'side');
 
     return (
         <div className={cn('w-full space-y-4', className)}>
-            {/* Mobile: Natural DOM order */}
-            <div className="md:hidden space-y-4">{children}</div>
-
-            {/* Desktop: Two-column layout */}
-            <div className="hidden md:grid md:grid-cols-5 lg:grid-cols-4 md:gap-4">
-                <div className="md:col-span-3 space-y-4">{mainBlocks}</div>
-                <div className="md:col-span-2 lg:col-span-1 space-y-4">{sideBlocks}</div>
-            </div>
+            {isDesktop ? (
+                <div className="hidden md:grid md:grid-cols-5 lg:grid-cols-4 md:gap-4">
+                    <div className="md:col-span-3 space-y-4">{mainBlocks}</div>
+                    <div className="md:col-span-2 lg:col-span-1 space-y-4">{sideBlocks}</div>
+                </div>
+            ) : (
+                <div className="md:hidden space-y-4">{children}</div>
+            )}
         </div>
     );
 }
 
+export function PageDetailForm({
+    children,
+    form,
+    submitHandler,
+}: {
+    children: React.ReactNode;
+    form: UseFormReturn<any>;
+    submitHandler: any;
+}) {
+    return (
+        <Form {...form}>
+            <form onSubmit={submitHandler} className="space-y-8">
+                {children}
+            </form>
+        </Form>
+    );
+}
+
+export function DetailFormGrid({ children }: { children: React.ReactNode }) {
+    return <div className="md:grid md:grid-cols-2 gap-4 items-start mb-4">{children}</div>;
+}
+
 export function Page({ children }: { children: React.ReactNode }) {
     return <div className="m-4">{children}</div>;
 }
@@ -53,7 +91,32 @@ export function PageTitle({ children }: { children: React.ReactNode }) {
 }
 
 export function PageActionBar({ children }: { children: React.ReactNode }) {
-    return <div className="flex justify-between gap-2">{children}</div>;
+    let childArray = React.Children.toArray(children);
+    // For some reason, sometimes the `children` prop is passed as this component itself, so we need to unwrap it
+    // This is a bit of a hack, but it works
+    if (childArray.length === 1 && (childArray[0] as React.ReactElement).type === PageActionBar) {
+        childArray = React.Children.toArray((childArray[0] as any)?.props?.children);
+    }
+    const leftContent = childArray.filter(
+        child => React.isValidElement(child) && (child.type as any)?.name === 'PageActionBarLeft',
+    );
+    const rightContent = childArray.filter(
+        child => React.isValidElement(child) && (child.type as any)?.name === 'PageActionBarRight',
+    );
+    return (
+        <div className="flex justify-between gap-2">
+            <div className="flex justify-start gap-2">{leftContent}</div>
+            <div className="flex justify-end gap-2">{rightContent}</div>
+        </div>
+    );
+}
+
+export function PageActionBarLeft({ children }: { children: React.ReactNode }) {
+    return <div className="flex justify-start gap-2">{children}</div>;
+}
+
+export function PageActionBarRight({ children }: { children: React.ReactNode }) {
+    return <div className="flex justify-end gap-2">{children}</div>;
 }
 
 export function PageBlock({ children, title, description, borderless }: PageBlockProps) {

+ 48 - 0
packages/dashboard/src/framework/page/detail-page-route-loader.tsx

@@ -0,0 +1,48 @@
+import { NEW_ENTITY_PATH } from '@/constants.js';
+
+import { PageBreadcrumb } from '@/components/layout/generated-breadcrumbs.js';
+import { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { FileBaseRouteOptions } from '@tanstack/react-router';
+import { getQueryName, getQueryTypeFieldInfo } from '../document-introspection/get-document-structure.js';
+import { DetailEntity } from './page-types.js';
+import { getDetailQueryOptions } from './use-detail-page.js';
+import { addCustomFields } from '../document-introspection/add-custom-fields.js';
+export interface DetailPageRouteLoaderConfig<T extends TypedDocumentNode<any, any>> {
+    queryDocument: T;
+    breadcrumb: (isNew: boolean, entity: DetailEntity<T>) => Array<PageBreadcrumb | undefined>;
+}
+
+export function detailPageRouteLoader<T extends TypedDocumentNode<any, any>>({
+    queryDocument,
+    breadcrumb,
+}: DetailPageRouteLoaderConfig<T>) {
+    const loader: FileBaseRouteOptions<any, any>['loader'] = async ({
+        context,
+        params,
+    }: {
+        context: any;
+        params: any;
+    }) => {
+        if (!params.id) {
+            throw new Error('ID param is required');
+        }
+        const isNew = params.id === NEW_ENTITY_PATH;
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(addCustomFields(queryDocument), { id: params.id }),
+                  { id: params.id },
+              );
+
+        const entityField = getQueryName(queryDocument);
+        const entityName = getQueryTypeFieldInfo(queryDocument)?.type;
+
+        if (!isNew && !result[entityField]) {
+            throw new Error(`${entityName} with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: breadcrumb(isNew, result?.[entityField]),
+        };
+    };
+    return loader;
+}

+ 6 - 6
packages/dashboard/src/framework/page/list-page.tsx

@@ -6,16 +6,14 @@ import {
     FacetedFilterConfig,
     ListQueryOptionsShape,
     ListQueryShape,
-    PaginatedListDataTable,
-    PaginatedListItemFields,
+    PaginatedListDataTable
 } from '@/components/shared/paginated-list-data-table.js';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { AnyRouter, useNavigate } from '@tanstack/react-router';
-import { ColumnDef, ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
+import { ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
 import { ResultOf } from 'gql.tada';
 import { Page, PageActionBar, PageTitle } from '../layout-engine/page-layout.js';
-import { FacetedFilter } from '@/components/data-table/data-table.js';
-import { customerListDocument } from '@/routes/_authenticated/_customers/customers.graphql.js';
+import { addCustomFields } from '../document-introspection/add-custom-fields.js';
 
 type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
     [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
@@ -106,12 +104,14 @@ export function ListPage<
         });
     }
 
+    const listQueryWithCustomFields = addCustomFields(listQuery);
+
     return (
         <Page>
             <PageTitle>{title}</PageTitle>
             <PageActionBar>{children}</PageActionBar>
             <PaginatedListDataTable
-                listQuery={listQuery}
+                listQuery={listQueryWithCustomFields}
                 transformVariables={transformVariables}
                 customizeColumns={customizeColumns}
                 additionalColumns={additionalColumns}

+ 51 - 0
packages/dashboard/src/framework/page/page-types.ts

@@ -1,3 +1,4 @@
+import { TypedDocumentNode, ResultOf } from '@graphql-typed-document-node/core';
 import { AnyRoute } from '@tanstack/react-router';
 import React from 'react';
 
@@ -5,3 +6,53 @@ export interface PageProps {
     title: string | React.ReactElement;
     route: AnyRoute | (() => AnyRoute);
 }
+
+// Type that identifies a paginated list structure (has items array and totalItems)
+type IsEntity<T> = T extends { id: string } ? true : false;
+
+// Helper type to extract string keys from an object
+type StringKeys<T> = T extends object ? Extract<keyof T, string> : never;
+
+/**
+ * @description
+ * This type is used to extract the path to the entity from the query result.
+ *
+ * For example, if you have a query like this:
+ *
+ * ```graphql
+ * query GetEntity($id: ID!) {
+ *   entity(id: $id) {
+ *     id
+ *     name
+ *   }
+ * }
+ * ```
+ *
+ * The `DetailEntityPath` type will be `'entity'`.
+ */
+export type DetailEntityPath<T extends TypedDocumentNode<any, any>> = {
+    [K in StringKeys<ResultOf<T>>]: NonNullable<ResultOf<T>[K]> extends object
+        ? IsEntity<NonNullable<ResultOf<T>[K]>> extends true
+            ? K
+            : never
+        : never;
+}[StringKeys<ResultOf<T>>];
+
+/**
+ * @description
+ * This type is used to extract the entity from the query result.
+ *
+ * For example, if you have a query like this:
+ *
+ * ```graphql
+ * query GetEntity($id: ID!) {
+ *   entity(id: $id) {
+ *     id
+ *     name
+ *   }
+ * }
+
+ * ```
+ * The `DetailEntity` type will be `{ id: string, name: string }`.
+ */
+export type DetailEntity<T extends TypedDocumentNode<any, any>> = ResultOf<T>[DetailEntityPath<T>];

+ 27 - 8
packages/dashboard/src/framework/page/use-detail-page.ts

@@ -4,17 +4,27 @@ import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { queryOptions, useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
 import { ResultOf, VariablesOf } from 'gql.tada';
 import { DocumentNode } from 'graphql';
-import { FormEvent, useCallback } from 'react';
+import { FormEvent } from 'react';
 import { UseFormReturn } from 'react-hook-form';
 
+import { addCustomFields } from '../document-introspection/add-custom-fields.js';
 import { getMutationName, getQueryName } from '../document-introspection/get-document-structure.js';
 import { useGeneratedForm } from '../form-engine/use-generated-form.js';
 
+import { DetailEntityPath } from './page-types.js';
+
+// Utility type to remove null from a type union
+type RemoveNull<T> = T extends null ? never : T;
+
+type RemoveNullFields<T> = {
+    [K in keyof T]: RemoveNull<T[K]>;
+};
+
 export interface DetailPageOptions<
     T extends TypedDocumentNode<any, any>,
     C extends TypedDocumentNode<any, any>,
     U extends TypedDocumentNode<any, any>,
-    EntityField extends keyof ResultOf<T> = keyof ResultOf<T>,
+    EntityField extends keyof ResultOf<T> = DetailEntityPath<T>,
     VarNameCreate extends keyof VariablesOf<C> = 'input',
     VarNameUpdate extends keyof VariablesOf<U> = 'input',
 > {
@@ -27,7 +37,7 @@ export interface DetailPageOptions<
      * @description
      * The field of the query document that contains the entity.
      */
-    entityField: EntityField;
+    entityField?: EntityField;
     /**
      * @description
      * The parameters used to identify the entity.
@@ -104,11 +114,12 @@ export interface UseDetailPageResult<
     U extends TypedDocumentNode<any, any>,
     EntityField extends keyof ResultOf<T>,
 > {
-    form: UseFormReturn<VariablesOf<U>['input']>;
+    form: UseFormReturn<RemoveNullFields<VariablesOf<U>['input']>>;
     submitHandler: (event: FormEvent<HTMLFormElement>) => void;
-    entity: DetailPageEntity<T, EntityField>;
+    entity?: DetailPageEntity<T, EntityField>;
     isPending: boolean;
     refreshEntity: () => void;
+    resetForm: () => void;
 }
 
 /**
@@ -140,9 +151,16 @@ export function useDetailPage<
     } = options;
     const isNew = params.id === NEW_ENTITY_PATH;
     const queryClient = useQueryClient();
-    const detailQueryOptions = getDetailQueryOptions(queryDocument, { id: isNew ? '__NEW__' : params.id });
+    const detailQueryOptions = getDetailQueryOptions(addCustomFields(queryDocument), {
+        id: isNew ? '__NEW__' : params.id,
+    });
     const detailQuery = useSuspenseQuery(detailQueryOptions);
-    const entity = detailQuery?.data[entityField] as DetailPageEntity<T, EntityField>;
+    const entityQueryField = entityField ?? getQueryName(queryDocument);
+    const entity = detailQuery?.data[entityQueryField] as DetailPageEntity<T, EntityField> | undefined;
+
+    const resetForm = () => {
+        form.reset(form.getValues());
+    };
 
     const createMutation = useMutation({
         mutationFn: createDocument ? api.mutate(createDocument) : undefined,
@@ -181,10 +199,11 @@ export function useDetailPage<
     });
 
     return {
-        form,
+        form: form as any,
         submitHandler,
         entity,
         isPending: updateMutation.isPending || detailQuery?.isPending,
         refreshEntity: detailQuery.refetch,
+        resetForm,
     };
 }

+ 16 - 15
packages/dashboard/src/routes/_authenticated/_administrators/administrators.tsx

@@ -1,15 +1,14 @@
-import { ListPage } from '@/framework/page/list-page.js';
-import { Link, createFileRoute } from '@tanstack/react-router';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { administratorListDocument } from './administrators.graphql.js';
-import { Trans } from '@lingui/react/macro';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
-import { PlusIcon } from 'lucide-react';
-import { Button } from '@/components/ui/button.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
-import { Badge } from '@/components/ui/badge.js';
 import { RoleCodeLabel } from '@/components/shared/role-code-label.js';
+import { Badge } from '@/components/ui/badge.js';
+import { Button } from '@/components/ui/button.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
+import { ListPage } from '@/framework/page/list-page.js';
+import { Trans } from '@lingui/react/macro';
+import { Link, createFileRoute } from '@tanstack/react-router';
+import { PlusIcon } from 'lucide-react';
+import { administratorListDocument } from './administrators.graphql.js';
 export const Route = createFileRoute('/_authenticated/_administrators/administrators')({
     component: AdministratorListPage,
     loader: () => ({ breadcrumb: () => <Trans>Administrators</Trans> }),
@@ -19,7 +18,7 @@ function AdministratorListPage() {
     return (
         <ListPage
             title="Administrators"
-            listQuery={addCustomFields(administratorListDocument)}
+            listQuery={administratorListDocument}
             route={Route}
             onSearchTermChange={searchTerm => {
                 return {
@@ -70,14 +69,16 @@ function AdministratorListPage() {
             defaultColumnOrder={['name', 'emailAddress', 'roles']}
         >
             <PageActionBar>
-                <PermissionGuard requires={['CreateAdministrator']}>
-                    <Button asChild>
-                        <Link to="./new">
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateAdministrator']}>
+                        <Button asChild>
+                            <Link to="./new">
                             <PlusIcon />
                             New Administrator
                         </Link>
-                    </Button>
-                </PermissionGuard>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 51 - 102
packages/dashboard/src/routes/_authenticated/_administrators/administrators_.$id.tsx

@@ -1,19 +1,22 @@
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { RoleSelector } from '@/components/shared/role-selector.js';
 import { Button } from '@/components/ui/button.js';
-import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form.js';
 import { Input } from '@/components/ui/input.js';
 import { NEW_ENTITY_PATH } from '@/constants.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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';
@@ -22,29 +25,20 @@ import {
     createAdministratorDocument,
     updateAdministratorDocument,
 } from './administrators.graphql.js';
-import { RoleSelector } from '@/components/shared/role-selector.js';
 import { RolePermissionsDisplay } from './components/role-permissions-display.js';
 
 export const Route = createFileRoute('/_authenticated/_administrators/administrators_/$id')({
     component: AdministratorDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(administratorDetailDocument, { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.administrator) {
-            throw new Error(`Administrator with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
+    loader: detailPageRouteLoader({
+        queryDocument: administratorDetailDocument,
+        breadcrumb: (isNew, entity) => {
+            const name = `${entity?.firstName} ${entity?.lastName}`;
+            return [
                 { path: '/administrators', label: 'Administrators' },
-                isNew ? <Trans>New administrator</Trans> : result.administrator.firstName,
-            ],
-        };
-    },
+                isNew ? <Trans>New administrator</Trans> : name,
+            ];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -54,9 +48,8 @@ export function AdministratorDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending } = useDetailPage({
-        queryDocument: addCustomFields(administratorDetailDocument),
-        entityField: 'administrator',
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        queryDocument: administratorDetailDocument,
         createDocument: createAdministratorDocument,
         updateDocument: updateAdministratorDocument,
         setValuesForUpdate: entity => {
@@ -81,9 +74,9 @@ export function AdministratorDetailPage() {
             toast(i18n.t('Successfully updated administrator'), {
                 position: 'top-right',
             });
-            form.reset(form.getValues());
+            resetForm();
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
@@ -100,81 +93,46 @@ export function AdministratorDetailPage() {
     return (
         <Page>
             <PageTitle>{creatingNewEntity ? <Trans>New administrator</Trans> : name}</PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateAdministrator']}>
-                            <Button
-                                type="submit"
-                                disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
-                            >
-                                <Trans>Update</Trans>
-                            </Button>
-                        </PermissionGuard>
+                                <Button
+                                    type="submit"
+                                    disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
+                                >
+                                    <Trans>Update</Trans>
+                                </Button>
+                            </PermissionGuard>
+                        </PageActionBarRight>
                     </PageActionBar>
                     <PageLayout>
                         <PageBlock column="main">
                             <div className="md:grid md:grid-cols-2 gap-4">
-                                <FormField
+                                <FormFieldWrapper
                                     control={form.control}
                                     name="firstName"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>First name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
+                                    label={<Trans>First name</Trans>}
+                                    render={({ field }) => <Input placeholder="" {...field} />}
                                 />
-                                <FormField
+                                <FormFieldWrapper
                                     control={form.control}
                                     name="lastName"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Last name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
+                                    label={<Trans>Last name</Trans>}
+                                    render={({ field }) => <Input placeholder="" {...field} />}
                                 />
-                                <FormField
+                                <FormFieldWrapper
                                     control={form.control}
                                     name="emailAddress"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Email Address or identifier</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
+                                    label={<Trans>Email Address or identifier</Trans>}
+                                    render={({ field }) => <Input placeholder="" {...field} />}
                                 />
-
-                                <FormField
+                                <FormFieldWrapper
                                     control={form.control}
                                     name="password"
+                                    label={<Trans>Password</Trans>}
                                     render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Password</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" type="password" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
+                                        <Input placeholder="" type="password" {...field} />
                                     )}
                                 />
                             </div>
@@ -185,30 +143,21 @@ export function AdministratorDetailPage() {
                             control={form.control}
                         />
                         <PageBlock column="main" title={<Trans>Roles</Trans>}>
-                            <FormField
+                            <FormFieldWrapper
                                 control={form.control}
                                 name="roleIds"
                                 render={({ field }) => (
-                                    <FormItem>
-                                        <FormControl>
-                                            <RoleSelector
-                                                value={field.value ?? []}
-                                                onChange={field.onChange}
-                                                multiple={true}
-                                            />
-                                        </FormControl>
-                                        <FormMessage />
-                                    </FormItem>
+                                    <RoleSelector
+                                        value={field.value ?? []}
+                                        onChange={field.onChange}
+                                        multiple={true}
+                                    />
                                 )}
                             />
-                            <RolePermissionsDisplay
-                                channels={entity?.user?.roles.flatMap(role => role.channels) ?? []}
-                                value={roleIds ?? []}
-                            />
+                            <RolePermissionsDisplay value={roleIds ?? []} />
                         </PageBlock>
                     </PageLayout>
-                </form>
-            </Form>
-        </Page>
+                </PageDetailForm>
+            </Page>
     );
 }

+ 4 - 4
packages/dashboard/src/routes/_authenticated/_assets/assets.tsx

@@ -10,10 +10,10 @@ export const Route = createFileRoute('/_authenticated/_assets/assets')({
 function RouteComponent() {
     return (
         <Page>
-            <PageTitle><Trans>Assets</Trans></PageTitle>
-            <PageActionBar>
-                <AssetGallery selectable={false} />
-            </PageActionBar>
+            <PageTitle>
+                <Trans>Assets</Trans>
+            </PageTitle>
+            <AssetGallery selectable={false} />
         </Page>
     );
 }

+ 24 - 18
packages/dashboard/src/routes/_authenticated/_channels/channels.tsx

@@ -1,14 +1,13 @@
-import { Link, createFileRoute } from '@tanstack/react-router';
-import { channelListQuery } from './channels.graphql.js';
-import { ListPage } from '@/framework/page/list-page.js';
-import { Button } from '@/components/ui/button.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
-import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { PlusIcon } from 'lucide-react';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { Button } from '@/components/ui/button.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
+import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@lingui/react/macro';
+import { Link, createFileRoute } from '@tanstack/react-router';
+import { PlusIcon } from 'lucide-react';
 import { ChannelCodeLabel } from '../../../components/shared/channel-code-label.js';
+import { channelListQuery } from './channels.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_channels/channels')({
     component: ChannelListPage,
@@ -19,7 +18,7 @@ function ChannelListPage() {
     return (
         <ListPage
             title="Channels"
-            listQuery={addCustomFields(channelListQuery)}
+            listQuery={channelListQuery}
             route={Route}
             defaultVisibility={{
                 code: true,
@@ -34,20 +33,27 @@ function ChannelListPage() {
                 code: {
                     header: 'Code',
                     cell: ({ row }) => {
-                        return <DetailPageButton id={row.original.id} label={<ChannelCodeLabel code={row.original.code} />} />;
+                        return (
+                            <DetailPageButton
+                                id={row.original.id}
+                                label={<ChannelCodeLabel code={row.original.code} />}
+                            />
+                        );
                     },
                 },
             }}
         >
             <PageActionBar>
-                <PermissionGuard requires={['CreateChannel']}>
-                    <Button asChild>
-                        <Link to="./new">
-                            <PlusIcon className="mr-2 h-4 w-4" />
-                            New Channel
-                        </Link>
-                    </Button>
-                </PermissionGuard>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateChannel']}>
+                        <Button asChild>
+                            <Link to="./new">
+                                <PlusIcon className="mr-2 h-4 w-4" />
+                                New Channel
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 149 - 243
packages/dashboard/src/routes/_authenticated/_channels/channels_.$id.tsx

@@ -1,60 +1,44 @@
+import { CurrencySelector } from '@/components/shared/currency-selector.js';
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
+import { LanguageSelector } from '@/components/shared/language-selector.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { SellerSelector } from '@/components/shared/seller-selector.js';
+import { ZoneSelector } from '@/components/shared/zone-selector.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 { DEFAULT_CHANNEL_CODE, NEW_ENTITY_PATH } from '@/constants.js';
 import {
     CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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 { createChannelDocument, channelDetailDocument, updateChannelDocument } from './channels.graphql.js';
-import { SellerSelector } from '@/components/shared/seller-selector.js';
-import { LanguageSelector } from '@/components/shared/language-selector.js';
-import { CurrencySelector } from '@/components/shared/currency-selector.js';
-import { ZoneSelector } from '@/components/shared/zone-selector.js';
-import { Badge } from '@/components/ui/badge.js';
-import { DEFAULT_CHANNEL_CODE } from '@/constants.js';
 import { ChannelCodeLabel } from '../../../components/shared/channel-code-label.js';
+import { channelDetailDocument, createChannelDocument, updateChannelDocument } from './channels.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_channels/channels_/$id')({
     component: ChannelDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(channelDetailDocument), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.channel) {
-            throw new Error(`Channel with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
+    loader: detailPageRouteLoader({
+        queryDocument: channelDetailDocument,
+        breadcrumb(isNew, entity) {
+            return [
                 { path: '/channels', label: 'Channels' },
-                isNew ? <Trans>New channel</Trans> : <ChannelCodeLabel code={result.channel.code ?? ''} />,
-            ],
-        };
-    },
+                isNew ? <Trans>New channel</Trans> : <ChannelCodeLabel code={entity?.code ?? ''} />,
+            ];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -64,9 +48,8 @@ export function ChannelDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending } = useDetailPage({
-        queryDocument: addCustomFields(channelDetailDocument),
-        entityField: 'channel',
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        queryDocument: channelDetailDocument,
         createDocument: createChannelDocument,
         updateDocument: updateChannelDocument,
         setValuesForUpdate: entity => {
@@ -97,9 +80,9 @@ export function ChannelDetailPage() {
                 toast(i18n.t('Successfully updated channel'), {
                     position: 'top-right',
                 });
-                form.reset(form.getValues());
+                resetForm();
                 if (creatingNewEntity) {
-                    await navigate({ to: `../${data?.id}`, from: Route.id });
+                    await navigate({ to: `../$id`, params: { id: data.id } });
                 }
             } else {
                 toast(i18n.t('Failed to update channel'), {
@@ -130,10 +113,9 @@ export function ChannelDetailPage() {
                     <ChannelCodeLabel code={entity?.code ?? ''} />
                 )}
             </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateChannel']}>
                             <Button
                                 type="submit"
@@ -142,204 +124,128 @@ export function ChannelDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 gap-4 items-start">
-                                <FormField
-                                    control={form.control}
-                                    name="code"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Code</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} disabled={codeIsDefault} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <div></div>
-                                <FormField
-                                    control={form.control}
-                                    name="token"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Token</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormDescription>
-                                                The token is used to specify the channel when making API
-                                                requests.
-                                            </FormDescription>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="sellerId"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Seller</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <SellerSelector
-                                                    value={field.value ?? ''}
-                                                    onChange={field.onChange}
-                                                />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="availableLanguageCodes"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Available languages</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <LanguageSelector
-                                                    value={field.value ?? []}
-                                                    onChange={field.onChange}
-                                                    multiple={true}
-                                                />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="availableCurrencyCodes"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Available currencies</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <CurrencySelector
-                                                    value={field.value ?? []}
-                                                    onChange={field.onChange}
-                                                    multiple={true}
-                                                />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                        </PageBlock>
-                        <PageBlock column="main" title={<Trans>Channel defaults</Trans>}>
-                            <div className="md:grid md:grid-cols-2 gap-4 items-start">
-                                <FormField
-                                    control={form.control}
-                                    name="defaultLanguageCode"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Default language</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <LanguageSelector
-                                                    value={field.value ?? ''}
-                                                    onChange={field.onChange}
-                                                    multiple={false}
-                                                    availableLanguageCodes={availableLanguageCodes ?? []}
-                                                />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="defaultCurrencyCode"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Default currency</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <CurrencySelector
-                                                    value={field.value ?? ''}
-                                                    onChange={field.onChange}
-                                                    multiple={false}
-                                                    availableCurrencyCodes={availableCurrencyCodes ?? []}
-                                                />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="defaultTaxZoneId"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Default tax zone</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <ZoneSelector
-                                                    value={field.value ?? ''}
-                                                    onChange={field.onChange}
-                                                />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="defaultShippingZoneId"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Default shipping zone</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <ZoneSelector
-                                                    value={field.value ?? ''}
-                                                    onChange={field.onChange}
-                                                />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="pricesIncludeTax"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Prices include tax for default tax zone</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Switch
-                                                    checked={field.value ?? false}
-                                                    onCheckedChange={field.onChange}
-                                                />
-                                            </FormControl>
-                                            <FormDescription>
-                                                <Trans>
-                                                    When this is enabled, the prices entered in the product
-                                                    catalog will be included in the tax for the default tax
-                                                    zone.
-                                                </Trans>
-                                            </FormDescription>
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                        </PageBlock>
-                        <CustomFieldsPageBlock column="main" entityType="Channel" control={form.control} />
-                    </PageLayout>
-                </form>
-            </Form>
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="code"
+                                label={<Trans>Code</Trans>}
+                                render={({ field }) => (
+                                    <Input placeholder="" {...field} disabled={codeIsDefault} />
+                                )}
+                            />
+                            <div></div>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="token"
+                                label={<Trans>Token</Trans>}
+                                description={
+                                    <Trans>
+                                        The token is used to specify the channel when making API requests.
+                                    </Trans>
+                                }
+                                render={({ field }) => <Input placeholder="" {...field} />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="sellerId"
+                                label={<Trans>Seller</Trans>}
+                                render={({ field }) => (
+                                    <SellerSelector value={field.value ?? ''} onChange={field.onChange} />
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="availableLanguageCodes"
+                                label={<Trans>Available languages</Trans>}
+                                render={({ field }) => (
+                                    <LanguageSelector
+                                        value={field.value ?? []}
+                                        onChange={field.onChange}
+                                        multiple={true}
+                                    />
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="availableCurrencyCodes"
+                                label={<Trans>Available currencies</Trans>}
+                                render={({ field }) => (
+                                    <CurrencySelector
+                                        value={field.value ?? []}
+                                        onChange={field.onChange}
+                                        multiple={true}
+                                    />
+                                )}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <PageBlock column="main" title={<Trans>Channel defaults</Trans>}>
+                        <DetailFormGrid>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="defaultLanguageCode"
+                                label={<Trans>Default language</Trans>}
+                                render={({ field }) => (
+                                    <LanguageSelector
+                                        value={field.value ?? ''}
+                                        onChange={field.onChange}
+                                        multiple={false}
+                                        availableLanguageCodes={availableLanguageCodes ?? []}
+                                    />
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="defaultCurrencyCode"
+                                label={<Trans>Default currency</Trans>}
+                                render={({ field }) => (
+                                    <CurrencySelector
+                                        value={field.value ?? ''}
+                                        onChange={field.onChange}
+                                        multiple={false}
+                                        availableCurrencyCodes={availableCurrencyCodes ?? []}
+                                    />
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="defaultTaxZoneId"
+                                label={<Trans>Default tax zone</Trans>}
+                                render={({ field }) => (
+                                    <ZoneSelector value={field.value ?? ''} onChange={field.onChange} />
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="defaultShippingZoneId"
+                                label={<Trans>Default shipping zone</Trans>}
+                                render={({ field }) => (
+                                    <ZoneSelector value={field.value ?? ''} onChange={field.onChange} />
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="pricesIncludeTax"
+                                label={<Trans>Prices include tax for default tax zone</Trans>}
+                                description={
+                                    <Trans>
+                                        When this is enabled, the prices entered in the product catalog will
+                                        be included in the tax for the default tax zone.
+                                    </Trans>
+                                }
+                                render={({ field }) => (
+                                    <Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
+                                )}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="Channel" control={form.control} />
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

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

@@ -81,6 +81,7 @@ export const collectionDetailDocument = graphql(
                     id
                     name
                 }
+                customFields
             }
         }
     `,

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

@@ -1,8 +1,7 @@
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { PageActionBar, PageActionBarLeft, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@lingui/react/macro';
 import { createFileRoute, Link } from '@tanstack/react-router';
@@ -42,12 +41,12 @@ export function CollectionListPage() {
                     header: 'Contents',
                     cell: ({ row }) => {
                         return (
-                                <CollectionContentsSheet
-                                    collectionId={row.original.id}
-                                    collectionName={row.original.name}
-                                >
-                                     <Trans>{row.original.productVariants.totalItems} variants</Trans>
-                                </CollectionContentsSheet>
+                            <CollectionContentsSheet
+                                collectionId={row.original.id}
+                                collectionName={row.original.name}
+                            >
+                                <Trans>{row.original.productVariants.totalItems} variants</Trans>
+                            </CollectionContentsSheet>
                         );
                     },
                 },
@@ -64,19 +63,20 @@ export function CollectionListPage() {
                     name: { contains: searchTerm },
                 };
             }}
-            listQuery={addCustomFields(collectionListDocument)}
+            listQuery={collectionListDocument}
             route={Route}
         >
             <PageActionBar>
-                <div></div>
-                <PermissionGuard requires={['CreateCollection', 'CreateCatalog']}>
-                    <Button asChild>
-                        <Link to="./new">
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateCollection', 'CreateCatalog']}>
+                        <Button asChild>
+                            <Link to="./new">
                             <PlusIcon className="mr-2 h-4 w-4" />
                             <Trans>New Collection</Trans>
                         </Link>
-                    </Button>
-                </PermissionGuard>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 124 - 179
packages/dashboard/src/routes/_authenticated/_collections/collections_.$id.tsx

@@ -1,64 +1,56 @@
-import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
 import { EntityAssets } from '@/components/shared/entity-assets.js';
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
+import {
+    TranslatableFormFieldWrapper
+} from '@/components/shared/translatable-form-field.js';
 import { Button } from '@/components/ui/button.js';
 import {
-    Form,
     FormControl,
     FormDescription,
-    FormField,
     FormItem,
     FormLabel,
-    FormMessage,
+    FormMessage
 } from '@/components/ui/form.js';
 import { Input } from '@/components/ui/input.js';
 import { Switch } from '@/components/ui/switch.js';
 import { Textarea } from '@/components/ui/textarea.js';
 import { NEW_ENTITY_PATH } from '@/constants.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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,
-    updateCollectionDocument
+    updateCollectionDocument,
 } from './collections.graphql.js';
+import { CollectionContentsPreviewTable } from './components/collection-contents-preview-table.js';
 import { CollectionContentsTable } from './components/collection-contents-table.js';
 import { CollectionFiltersSelector } from './components/collection-filters-selector.js';
-import { CollectionContentsPreviewTable } from './components/collection-contents-preview-table.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,
-            ],
-        };
-    },
+    loader: detailPageRouteLoader({
+        queryDocument: collectionDetailDocument,
+        breadcrumb: (isNew, entity) => [
+            { path: '/collections', label: 'Collections' },
+            isNew ? <Trans>New collection</Trans> : entity?.name,
+        ],
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -68,9 +60,8 @@ export function CollectionDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
-        queryDocument: addCustomFields(collectionDetailDocument),
-        entityField: 'collection',
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        queryDocument: collectionDetailDocument,
         createDocument: createCollectionDocument,
         transformCreateInput: values => {
             return {
@@ -106,9 +97,9 @@ export function CollectionDetailPage() {
             toast(i18n.t('Successfully updated collection'), {
                 position: 'top-right',
             });
-            form.reset(form.getValues());
+            resetForm();
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
@@ -119,7 +110,6 @@ export function CollectionDetailPage() {
         },
     });
 
-
     const shouldPreviewContents =
         form.getFieldState('inheritFilters').isDirty || form.getFieldState('filters').isDirty;
 
@@ -129,10 +119,9 @@ export function CollectionDetailPage() {
     return (
         <Page>
             <PageTitle>{creatingNewEntity ? <Trans>New collection</Trans> : (entity?.name ?? '')}</PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <ContentLanguageSelector />
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateCollection', 'UpdateCatalog']}>
                             <Button
                                 type="submit"
@@ -141,153 +130,109 @@ export function CollectionDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="side">
-                            <FormField
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="side">
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="isPrivate"
+                            label={<Trans>Private</Trans>}
+                            description={<Trans>Private collections are not visible in the shop</Trans>}
+                            render={({ field }) => (
+                                <Switch checked={field.value} onCheckedChange={field.onChange} />
+                            )}
+                        />
+                    </PageBlock>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <TranslatableFormFieldWrapper
                                 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>
-                                )}
+                                name="name"
+                                label={<Trans>Name</Trans>}
+                                render={({ field }) => <Input placeholder="" {...field} />}
                             />
-                        </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
+                            <TranslatableFormFieldWrapper
                                 control={form.control}
-                                name="description"
-                                render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Description</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Textarea placeholder="" {...field} />
-                                        </FormControl>
-                                        <FormMessage />
-                                    </FormItem>
-                                )}
+                                name="slug"
+                                label={<Trans>Slug</Trans>}
+                                render={({ field }) => <Input placeholder="" {...field} />}
                             />
-                        </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 }) => (
-                                    <CollectionFiltersSelector value={field.value ?? []} onChange={field.onChange} />
-                                )}
+                        </DetailFormGrid>
+                        <TranslatableFormFieldWrapper
+                            control={form.control}
+                            name="description"
+                            label={<Trans>Description</Trans>}
+                            render={({ field }) => <Textarea placeholder="" {...field} />}
+                        />
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="Collection" control={form.control} />
+                    <PageBlock column="main" title={<Trans>Filters</Trans>}>
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="inheritFilters"
+                            label={<Trans>Inherit filters</Trans>}
+                            description={
+                                <Trans>
+                                    If enabled, the filters will be inherited from the parent collection and
+                                    combined with the filters set on this collection.
+                                </Trans>
+                            }
+                            render={({ field }) => (
+                                <Switch checked={field.value} onCheckedChange={field.onChange} />
+                            )}
+                        />
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="filters"
+                            render={({ field }) => (
+                                <CollectionFiltersSelector
+                                    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>
+                    <PageBlock column="main" title={<Trans>Facet values</Trans>}>
+                        {shouldPreviewContents || creatingNewEntity ? (
+                            <CollectionContentsPreviewTable
+                                parentId={entity?.parent?.id}
+                                filters={currentFiltersValue ?? []}
+                                inheritFilters={currentInheritFiltersValue ?? false}
                             />
-                        </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>
-                            <PageBlock column="main" title={<Trans>Facet values</Trans>}>
-                                {shouldPreviewContents || creatingNewEntity ? (
-                                    <CollectionContentsPreviewTable
-                                        parentId={entity?.parent?.id}
-                                        filters={currentFiltersValue}
-                                        inheritFilters={currentInheritFiltersValue}
-                                    />
-                                ) : (
-                                    <CollectionContentsTable collectionId={entity?.id} />
-                                )}
-                            </PageBlock>
-                    </PageLayout>
-                </form>
-            </Form>
+                        ) : (
+                            <CollectionContentsTable collectionId={entity?.id} />
+                        )}
+                    </PageBlock>
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 13 - 13
packages/dashboard/src/routes/_authenticated/_countries/countries.tsx

@@ -1,9 +1,7 @@
-import { BooleanDisplayBadge } from '@/components/data-display/boolean.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@lingui/react/macro';
 import { Link, createFileRoute } from '@tanstack/react-router';
@@ -18,7 +16,7 @@ export const Route = createFileRoute('/_authenticated/_countries/countries')({
 function CountryListPage() {
     return (
         <ListPage
-            listQuery={addCustomFields(countriesListQuery)}
+            listQuery={countriesListQuery}
             route={Route}
             title="Countries"
             defaultVisibility={{
@@ -42,7 +40,7 @@ function CountryListPage() {
                     options: {
                         ...variables.options,
                         filterOperator: 'OR',
-                    }
+                    },
                 };
             }}
             customizeColumns={{
@@ -53,14 +51,16 @@ function CountryListPage() {
             }}
         >
             <PageActionBar>
-                <PermissionGuard requires={['CreateCountry']}>
-                    <Button asChild>
-                        <Link to="./new">
-                            <PlusIcon />
-                            <Trans>Add Country</Trans>
-                        </Link>
-                    </Button>
-                </PermissionGuard>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateCountry']}>
+                        <Button asChild>
+                            <Link to="./new">
+                                <PlusIcon />
+                                <Trans>Add Country</Trans>
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 52 - 97
packages/dashboard/src/routes/_authenticated/_countries/countries_.$id.tsx

@@ -1,49 +1,37 @@
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
+import { TranslatableFormFieldWrapper } from '@/components/shared/translatable-form-field.js';
 import { Button } from '@/components/ui/button.js';
-import { Form, FormControl, 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,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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 {
-    countryDetailQuery,
-    createCountryDocument,
-    updateCountryDocument,
-} from './countries.graphql.js';
-import { Switch } from '@/components/ui/switch.js';
+import { countryDetailQuery, createCountryDocument, updateCountryDocument } from './countries.graphql.js';
 export const Route = createFileRoute('/_authenticated/_countries/countries_/$id')({
     component: CountryDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(countryDetailQuery), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.country) {
-            throw new Error(`Country with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
-                { path: '/countries', label: 'Countries' },
-                    isNew ? <Trans>New country</Trans> : result.country.name,
-            ],
-        };
-    },
+    loader: detailPageRouteLoader({
+        queryDocument: countryDetailQuery,
+        breadcrumb: (isNew, entity) => [
+            { path: '/countries', label: 'Countries' },
+            isNew ? <Trans>New country</Trans> : entity?.name,
+        ],
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -54,8 +42,7 @@ export function CountryDetailPage() {
     const { i18n } = useLingui();
 
     const { form, submitHandler, entity, isPending } = useDetailPage({
-        queryDocument: addCustomFields(countryDetailQuery),
-        entityField: 'country',
+        queryDocument: countryDetailQuery,
         createDocument: createCountryDocument,
         updateDocument: updateCountryDocument,
         setValuesForUpdate: entity => {
@@ -76,7 +63,7 @@ export function CountryDetailPage() {
             });
             form.reset(form.getValues());
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
@@ -89,13 +76,10 @@ export function CountryDetailPage() {
 
     return (
         <Page>
-            <PageTitle>
-                {creatingNewEntity ? <Trans>New country</Trans> : (entity?.name ?? '')}
-            </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageTitle>{creatingNewEntity ? <Trans>New country</Trans> : (entity?.name ?? '')}</PageTitle>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateCountry']}>
                             <Button
                                 type="submit"
@@ -104,67 +88,38 @@ export function CountryDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column='side'>
-                            <FormField
-                                control={form.control}
-                                name="enabled"
-                                render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Enabled</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Switch checked={field.value} onCheckedChange={field.onChange} />
-                                        </FormControl>
-                                        <FormMessage />
-                                    </FormItem>
-                                )}
-                            />
-                        </PageBlock>
-                        <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 gap-4">
-                                    <TranslatableFormField
-                                        control={form.control}
-                                        name="name"
-                                        render={({ field }) => (
-                                            <FormItem>
-                                                <FormLabel>
-                                                    <Trans>Name</Trans>
-                                                </FormLabel>
-                                                <FormControl>
-                                                    <Input placeholder="" {...field} />
-                                                </FormControl>
-                                                <FormMessage />
-                                            </FormItem>
-                                        )}
-                                    />
-                                    <FormField
-                                        control={form.control}
-                                        name="code"
-                                        render={({ field }) => (
-                                            <FormItem>
-                                                <FormLabel>
-                                                    <Trans>Code</Trans>
-                                                </FormLabel>
-                                                <FormControl>
-                                                    <Input placeholder="" {...field} />
-                                                </FormControl>
-                                                <FormMessage />
-                                            </FormItem>
-                                        )}
-                                    />
-                            </div>
-                        </PageBlock>
-                        <CustomFieldsPageBlock
-                            column="main"
-                            entityType="Country"
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="side">
+                        <FormFieldWrapper
                             control={form.control}
+                            label={<Trans>Enabled</Trans>}
+                            name="enabled"
+                            render={({ field }) => (
+                                <Switch checked={field.value} onCheckedChange={field.onChange} />
+                            )}
                         />
-                    </PageLayout>
-                </form>
-            </Form>
+                    </PageBlock>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <TranslatableFormFieldWrapper
+                                control={form.control}
+                                name="name"
+                                label={<Trans>Name</Trans>}
+                                render={({ field }) => <Input placeholder="" {...field} />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="code"
+                                label={<Trans>Code</Trans>}
+                                render={({ field }) => <Input placeholder="" {...field} />}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="Country" control={form.control} />
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 1 - 0
packages/dashboard/src/routes/_authenticated/_customer-groups/customer-groups.graphql.ts

@@ -12,6 +12,7 @@ export const customerGroupListDocument = graphql(`
                     totalItems
                 }
             }
+            totalItems
         }
     }
 `);

+ 28 - 27
packages/dashboard/src/routes/_authenticated/_customer-groups/customer-groups.tsx

@@ -1,8 +1,7 @@
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@lingui/react/macro';
 import { Link, createFileRoute } from '@tanstack/react-router';
@@ -19,7 +18,7 @@ function CustomerGroupListPage() {
     return (
         <ListPage
             title="Customer Groups"
-            listQuery={addCustomFields(customerGroupListDocument)}
+            listQuery={customerGroupListDocument}
             route={Route}
             customizeColumns={{
                 name: {
@@ -27,22 +26,22 @@ function CustomerGroupListPage() {
                     cell: ({ row }) => <DetailPageButton id={row.original.id} label={row.original.name} />,
                 },
                 customers: {
-                  header: () => <Trans>Values</Trans>,
-                  cell: ({ cell }) => {
-                      const value = cell.getValue();
-                      if (!value) return null;
-                      return (
-                          <div className="flex flex-wrap gap-2 items-center">
-                              <CustomerGroupMembersSheet
-                                  customerGroupId={cell.row.original.id}
-                                  customerGroupName={cell.row.original.name}
-                              >
-                                  <Trans>{value.totalItems} customers</Trans>
-                              </CustomerGroupMembersSheet>
-                          </div>
-                      );
-                  },
-              },
+                    header: () => <Trans>Values</Trans>,
+                    cell: ({ cell }) => {
+                        const value = cell.getValue();
+                        if (!value) return null;
+                        return (
+                            <div className="flex flex-wrap gap-2 items-center">
+                                <CustomerGroupMembersSheet
+                                    customerGroupId={cell.row.original.id}
+                                    customerGroupName={cell.row.original.name}
+                                >
+                                    <Trans>{value.totalItems} customers</Trans>
+                                </CustomerGroupMembersSheet>
+                            </div>
+                        );
+                    },
+                },
             }}
             onSearchTermChange={searchTerm => {
                 return {
@@ -51,14 +50,16 @@ function CustomerGroupListPage() {
             }}
         >
             <PageActionBar>
-                <PermissionGuard requires={['CreateCustomerGroup']}>
-                    <Button asChild>
-                        <Link to="./new">
-                            <PlusIcon className="mr-2 h-4 w-4" />
-                            <Trans>New Customer Group</Trans>
-                        </Link>
-                    </Button>
-                </PermissionGuard>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateCustomerGroup']}>
+                        <Button asChild>
+                            <Link to="./new">
+                                <PlusIcon className="mr-2 h-4 w-4" />
+                                <Trans>New Customer Group</Trans>
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 12 - 145
packages/dashboard/src/routes/_authenticated/_customer-groups/customer-groups_.$id.tsx

@@ -1,146 +1,13 @@
-import { ErrorPage } from '@/components/shared/error-page.js';
-import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { Button } from '@/components/ui/button.js';
-import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form.js';
-import { Input } from '@/components/ui/input.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 { CustomerGroupMembersTable } from './components/customer-group-members-table.js';
-import {
-    createCustomerGroupDocument,
-    customerGroupDocument,
-    updateCustomerGroupDocument,
-} from './customer-groups.graphql.js';
-import { CustomerSelector } from '@/components/shared/customer-selector.js';
-import { api } from '@/graphql/api.js';
-import { addCustomerToGroupDocument } from '../_customers/customers.graphql.js';
-import { useMutation } from '@tanstack/react-query';
-
-export const Route = createFileRoute('/_authenticated/_customer-groups/customer-groups_/$id')({
-    component: CustomerGroupDetailPage,
-    loader: async ({ context, params }) => {
-        console.log('params', params);
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(customerGroupDocument), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.customerGroup) {
-            throw new Error(`Customer group with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
-                { path: '/customer-groups', label: 'Customer groups' },
-                isNew ? <Trans>New customer group</Trans> : result.customerGroup.name,
-            ],
-        };
-    },
-    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
-});
-
-export function CustomerGroupDetailPage() {
-    const params = Route.useParams();
-    const navigate = useNavigate();
-    const creatingNewEntity = params.id === NEW_ENTITY_PATH;
-    const { i18n } = useLingui();
-
-    const { form, submitHandler, entity, isPending } = useDetailPage({
-        queryDocument: addCustomFields(customerGroupDocument),
-        entityField: 'customerGroup',
-        createDocument: createCustomerGroupDocument,
-        updateDocument: updateCustomerGroupDocument,
-        setValuesForUpdate: entity => {
-            return {
-                id: entity.id,
-                name: entity.name,
-                customFields: entity.customFields,
-            };
-        },
-        params: { id: params.id },
-        onSuccess: async data => {
-            toast(i18n.t('Successfully updated customer group'), {
-                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 customer group'), {
-                position: 'top-right',
-                description: err instanceof Error ? err.message : 'Unknown error',
-            });
-        },
-    });
-
-    return (
-        <Page>
-            <PageTitle>
-                {creatingNewEntity ? <Trans>New customer group</Trans> : (entity?.name ?? '')}
-            </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
-                        <PermissionGuard requires={['UpdateCustomerGroup']}>
-                            <Button
-                                type="submit"
-                                disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
-                            >
-                                <Trans>Update</Trans>
-                            </Button>
-                        </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="main">
-                            <div className="md:flex w-full gap-4">
-                                <div className="w-1/2">
-                                    <FormField
-                                        control={form.control}
-                                        name="name"
-                                        render={({ field }) => (
-                                            <FormItem>
-                                                <FormLabel>
-                                                    <Trans>Name</Trans>
-                                                </FormLabel>
-                                                <FormControl>
-                                                    <Input placeholder="" {...field} />
-                                                </FormControl>
-                                                <FormMessage />
-                                            </FormItem>
-                                        )}
-                                    />
-                                </div>
-                            </div>
-                        </PageBlock>
-                        <CustomFieldsPageBlock
-                            column="main"
-                            entityType="CustomerGroup"
-                            control={form.control}
-                        />
-                        {!creatingNewEntity && (
-                            <PageBlock column="main" title={<Trans>Customers</Trans>}>
-                                <CustomerGroupMembersTable customerGroupId={entity?.id} />
-                            </PageBlock>
-                        )}
-                    </PageLayout>
-                </form>
-            </Form>
-        </Page>
-    );
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute(
+  '/_authenticated/_customer-groups/customer-groups_/$id',
+)({
+  component: RouteComponent,
+})
+
+function RouteComponent() {
+  return (
+    <div>Hello "/_authenticated/_customer-groups/customer-groups_/$id"!</div>
+  )
 }

+ 1 - 0
packages/dashboard/src/routes/_authenticated/_customers/customers.graphql.ts

@@ -69,6 +69,7 @@ export const customerDetailDocument = graphql(
                 addresses {
                     ...Address
                 }
+                customFields
             }
         }
     `,

+ 22 - 5
packages/dashboard/src/routes/_authenticated/_customers/customers.tsx

@@ -1,10 +1,14 @@
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import { ListPage } from '@/framework/page/list-page.js';
-import { createFileRoute } from '@tanstack/react-router';
+import { Trans } from '@lingui/react/macro';
+import { createFileRoute, Link } from '@tanstack/react-router';
 import { CustomerStatusBadge } from './components/customer-status-badge.js';
 import { customerListDocument } from './customers.graphql.js';
-import { Trans } from '@lingui/react/macro';
+import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { PlusIcon } from 'lucide-react';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { Button } from '@/components/ui/button.js';
+import { PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 export const Route = createFileRoute('/_authenticated/_customers/customers')({
     component: CustomerListPage,
     loader: () => ({ breadcrumb: () => <Trans>Customers</Trans> }),
@@ -32,7 +36,7 @@ export function CustomerListPage() {
                     },
                 };
             }}
-            listQuery={addCustomFields(customerListDocument)}
+            listQuery={customerListDocument}
             route={Route}
             customizeColumns={{
                 user: {
@@ -60,6 +64,19 @@ export function CustomerListPage() {
                 firstName: false,
                 lastName: false,
             }}
-        />
+        >
+            <PageActionBar>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateCustomer']}>
+                        <Button asChild>
+                            <Link to="./new">
+                                <PlusIcon />
+                                <Trans>New Customer</Trans>
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
+            </PageActionBar>
+        </ListPage>
     );
 }

+ 169 - 214
packages/dashboard/src/routes/_authenticated/_customers/customers_.$id.tsx

@@ -1,69 +1,62 @@
-import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
+import { CustomerGroupChip } from '@/components/shared/customer-group-chip.js';
+import { CustomerGroupSelector } from '@/components/shared/customer-group-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, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogHeader,
+    DialogTitle,
+    DialogTrigger,
+} from '@/components/ui/dialog.js';
 import { Input } from '@/components/ui/input.js';
 import { NEW_ENTITY_PATH } from '@/constants.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { useDetailPage } from '@/framework/page/use-detail-page.js';
+import { api } from '@/graphql/api.js';
 import { Trans, useLingui } from '@lingui/react/macro';
+import { useMutation } from '@tanstack/react-query';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { Plus } from 'lucide-react';
+import { useState } from 'react';
 import { toast } from 'sonner';
+import { CustomerAddressCard } from './components/customer-address-card.js';
+import { CustomerAddressForm } from './components/customer-address-form.js';
+import { CustomerHistoryContainer } from './components/customer-history/customer-history-container.js';
+import { CustomerOrderTable } from './components/customer-order-table.js';
+import { CustomerStatusBadge } from './components/customer-status-badge.js';
 import {
+    addCustomerToGroupDocument,
+    createCustomerAddressDocument,
     createCustomerDocument,
     customerDetailDocument,
-    updateCustomerDocument,
-    createCustomerAddressDocument,
     removeCustomerFromGroupDocument,
-    addCustomerToGroupDocument,
+    updateCustomerDocument,
 } from './customers.graphql.js';
-import { CustomerAddressCard } from './components/customer-address-card.js';
-import { DialogHeader, DialogTitle, DialogDescription, DialogTrigger, DialogContent, Dialog } from '@/components/ui/dialog.js';
-import { EditIcon, Plus } from 'lucide-react';
-import { CustomerAddressForm } from './components/customer-address-form.js';
-import { useState } from 'react';
-import { api } from '@/graphql/api.js';
-import { useMutation } from '@tanstack/react-query';
-import { CustomerOrderTable } from './components/customer-order-table.js';
-import { CustomerHistoryContainer } from './components/customer-history/customer-history-container.js';
-import { CustomerGroupSelector } from '@/components/shared/customer-group-selector.js';
-import { CustomerGroupChip } from '@/components/shared/customer-group-chip.js';
-import { CustomerStatusBadge } from './components/customer-status-badge.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 
 export const Route = createFileRoute('/_authenticated/_customers/customers_/$id')({
     component: CustomerDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(customerDetailDocument), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.customer) {
-            throw new Error(`Customer with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
-                { path: '/customers', label: 'Customers' },
-                isNew ? (
-                    <Trans>New customer</Trans>
-                ) : (
-                    `${result.customer.firstName} ${result.customer.lastName}`
-                ),
-            ],
-        };
-    },
+    loader: detailPageRouteLoader({
+        queryDocument: customerDetailDocument,
+        breadcrumb: (isNew, entity) => [
+            { path: '/customers', label: 'Customers' },
+            isNew ? <Trans>New customer</Trans> : `${entity?.firstName} ${entity?.lastName}`,
+        ],
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -74,9 +67,8 @@ export function CustomerDetailPage() {
     const { i18n } = useLingui();
     const [newAddressOpen, setNewAddressOpen] = useState(false);
 
-    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
-        queryDocument: addCustomFields(customerDetailDocument),
-        entityField: 'customer',
+    const { form, submitHandler, entity, isPending, refreshEntity, resetForm } = useDetailPage({
+        queryDocument: customerDetailDocument,
         createDocument: createCustomerDocument,
         updateDocument: updateCustomerDocument,
         setValuesForUpdate: entity => {
@@ -94,23 +86,19 @@ export function CustomerDetailPage() {
         params: { id: params.id },
         onSuccess: async data => {
             if (data.__typename === 'Customer') {
-                toast(i18n.t('Successfully updated customer'), {
-                    position: 'top-right',
-                });
-                form.reset(form.getValues());
+                toast.success(i18n.t('Successfully updated customer'));
+                resetForm();
                 if (creatingNewEntity) {
-                    await navigate({ to: `../${data?.id}`, from: Route.id });
+                    await navigate({ to: `../$id`, params: { id: data.id } });
                 }
             } else {
-                toast(i18n.t('Failed to update customer'), {
-                    position: 'top-right',
+                toast.error(i18n.t('Failed to update customer'), {
                     description: data.message,
                 });
             }
         },
         onError: err => {
-            toast(i18n.t('Failed to update customer'), {
-                position: 'top-right',
+            toast.error(i18n.t('Failed to update customer'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -123,7 +111,7 @@ export function CustomerDetailPage() {
             refreshEntity();
         },
         onError: () => {
-            toast(i18n.t('Failed to create address'), { position: 'top-right' });
+            toast.error(i18n.t('Failed to create address'));
         },
     });
 
@@ -133,7 +121,7 @@ export function CustomerDetailPage() {
             refreshEntity();
         },
         onError: () => {
-            toast(i18n.t('Failed to add customer to group'), { position: 'top-right' });
+            toast(i18n.t('Failed to add customer to group'));
         },
     });
 
@@ -143,19 +131,19 @@ export function CustomerDetailPage() {
             refreshEntity();
         },
         onError: () => {
-            toast(i18n.t('Failed to remove customer from group'), { position: 'top-right' });
+            toast(i18n.t('Failed to remove customer from group'));
         },
     });
 
     const customerName = entity ? `${entity.firstName} ${entity.lastName}` : '';
+    console.log(entity);
 
     return (
         <Page>
             <PageTitle>{creatingNewEntity ? <Trans>New customer</Trans> : customerName}</PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateCustomer']}>
                             <Button
                                 type="submit"
@@ -164,160 +152,127 @@ export function CustomerDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        {/*  <PageBlock column="side"></PageBlock> */}
-                        <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 w-full gap-4">
-                                <FormField
-                                    control={form.control}
-                                    name="title"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Title</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <div></div>
-                                <FormField
-                                    control={form.control}
-                                    name="firstName"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>First name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="lastName"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Last name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="emailAddress"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Email address</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="phoneNumber"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Phone number</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                        </PageBlock>
-                        <CustomFieldsPageBlock column="main" entityType="Customer" control={form.control} />
-                        <PageBlock column="main" title={<Trans>Addresses</Trans>}>
-                            <div className="md:grid md:grid-cols-2 gap-4 mb-4">
-                                {entity?.addresses?.map(address => (
-                                    <CustomerAddressCard
-                                        key={address.id}
-                                        address={address}
-                                        editable
-                                        deletable
-                                        onUpdate={() => {
-                                            refreshEntity();
-                                        }}
-                                        onDelete={() => {
-                                            refreshEntity();
-                                        }}
-                                    />
-                                ))}
-                            </div>
-                            <Dialog open={newAddressOpen} onOpenChange={setNewAddressOpen}>
-                                <DialogTrigger asChild>
-                                    <Button variant="outline">
-                                        <Plus className="w-4 h-4" /> <Trans>Add new address</Trans>
-                                    </Button>
-                                </DialogTrigger>
-                                <DialogContent>
-                                    <DialogHeader>
-                                        <DialogTitle>
-                                            <Trans>Add new address</Trans>
-                                        </DialogTitle>
-                                        <DialogDescription>
-                                            <Trans>Add a new address to the customer.</Trans>
-                                        </DialogDescription>
-                                    </DialogHeader>
-                                    <CustomerAddressForm
-                                        onSubmit={values => {
-                                            const { id, ...input } = values;
-                                            createAddress({
-                                                customerId: entity.id,
-                                                input,
-                                            });
-                                        }}
-                                    />
-                                </DialogContent>
-                            </Dialog>
-                        </PageBlock>
-                        <PageBlock column="main" title={<Trans>Orders</Trans>}>
-                            <CustomerOrderTable customerId={entity.id} />
-                        </PageBlock>
-                        <PageBlock column="main" title={<Trans>Customer history</Trans>}>
-                            <CustomerHistoryContainer customerId={entity.id} />
-                        </PageBlock>
-                        <PageBlock column="side" title={<Trans>Status</Trans>}>
-                            <CustomerStatusBadge user={entity.user} />
-                        </PageBlock>
-                        <PageBlock column="side" title={<Trans>Customer groups</Trans>}>
-                            <div className={`flex flex-col gap-2 ${entity?.groups?.length > 0 ? 'mb-2' : ''}`}>
-                                {entity?.groups?.map(group => (
-                                    <CustomerGroupChip
-                                        key={group.id}
-                                        group={group}
-                                        onRemove={groupId => removeCustomerFromGroup({ customerId: entity.id, groupId })}
-                                    />
-                                ))}
-                            </div>
-                            <CustomerGroupSelector
-                                onSelect={group => addCustomerToGroup({ customerId: entity.id, groupId: group.id })}
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="title"
+                                label={<Trans>Title</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                            <div></div>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="firstName"
+                                label={<Trans>First name</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="lastName"
+                                label={<Trans>Last name</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="emailAddress"
+                                label={<Trans>Email address</Trans>}
+                                render={({ field }) => <Input {...field} />}
                             />
-                        </PageBlock>
-                    </PageLayout>
-                </form>
-            </Form>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="phoneNumber"
+                                label={<Trans>Phone number</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="Customer" control={form.control} />
+
+                    {entity && (
+                        <>
+                            <PageBlock column="main" title={<Trans>Addresses</Trans>}>
+                                <DetailFormGrid>
+                                    {entity?.addresses?.map(address => (
+                                        <CustomerAddressCard
+                                            key={address.id}
+                                            address={address}
+                                            editable
+                                            deletable
+                                            onUpdate={() => {
+                                                refreshEntity();
+                                            }}
+                                            onDelete={() => {
+                                                refreshEntity();
+                                            }}
+                                        />
+                                    ))}
+                                </DetailFormGrid>
+
+                                <Dialog open={newAddressOpen} onOpenChange={setNewAddressOpen}>
+                                    <DialogTrigger asChild>
+                                        <Button variant="outline">
+                                            <Plus className="w-4 h-4" /> <Trans>Add new address</Trans>
+                                        </Button>
+                                    </DialogTrigger>
+                                    <DialogContent>
+                                        <DialogHeader>
+                                            <DialogTitle>
+                                                <Trans>Add new address</Trans>
+                                            </DialogTitle>
+                                            <DialogDescription>
+                                                <Trans>Add a new address to the customer.</Trans>
+                                            </DialogDescription>
+                                        </DialogHeader>
+                                        <CustomerAddressForm
+                                            onSubmit={values => {
+                                                const { id, ...input } = values;
+                                                createAddress({
+                                                    customerId: entity.id,
+                                                    input,
+                                                });
+                                            }}
+                                        />
+                                    </DialogContent>
+                                </Dialog>
+                            </PageBlock>
+
+                            <PageBlock column="main" title={<Trans>Orders</Trans>}>
+                                <CustomerOrderTable customerId={entity.id} />
+                            </PageBlock>
+                            <PageBlock column="main" title={<Trans>Customer history</Trans>}>
+                                <CustomerHistoryContainer customerId={entity.id} />
+                            </PageBlock>
+                            <PageBlock column="side" title={<Trans>Status</Trans>}>
+                                <CustomerStatusBadge user={entity.user} />
+                            </PageBlock>
+                            <PageBlock column="side" title={<Trans>Customer groups</Trans>}>
+                                <div
+                                    className={`flex flex-col gap-2 ${entity?.groups?.length > 0 ? 'mb-2' : ''}`}
+                                >
+                                    {entity?.groups?.map(group => (
+                                        <CustomerGroupChip
+                                            key={group.id}
+                                            group={group}
+                                            onRemove={groupId =>
+                                                removeCustomerFromGroup({ customerId: entity.id, groupId })
+                                            }
+                                        />
+                                    ))}
+                                </div>
+                                <CustomerGroupSelector
+                                    onSelect={group =>
+                                        addCustomerToGroup({ customerId: entity.id, groupId: group.id })
+                                    }
+                                />
+                            </PageBlock>
+                        </>
+                    )}
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

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

@@ -1,17 +1,16 @@
 import { FacetValueChip } from '@/components/shared/facet-value-chip.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@lingui/react/macro';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
 import { facetListDocument } from './facets.graphql.js';
 
+import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { ResultOf } from 'gql.tada';
 import { FacetValuesSheet } from './components/facet-values-sheet.js';
-import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 export const Route = createFileRoute('/_authenticated/_facets/facets')({
     component: FacetListPage,
     loader: () => ({ breadcrumb: () => <Trans>Facets</Trans> }),
@@ -66,7 +65,7 @@ export function FacetListPage() {
                     name: { contains: searchTerm },
                 };
             }}
-            listQuery={addCustomFields(facetListDocument)}
+            listQuery={facetListDocument}
             transformVariables={variables => {
                 return {
                     ...variables,
@@ -78,15 +77,16 @@ export function FacetListPage() {
             route={Route}
         >
             <PageActionBar>
-                <div></div>
-                <PermissionGuard requires={['CreateFacet', 'CreateCatalog']}>
-                    <Button asChild>
-                        <Link to="./new">
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateFacet', 'CreateCatalog']}>
+                        <Button asChild>
+                            <Link to="./new">
                             <PlusIcon className="mr-2 h-4 w-4" />
                             <Trans>New Facet</Trans>
                         </Link>
-                    </Button>
-                </PermissionGuard>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 57 - 108
packages/dashboard/src/routes/_authenticated/_facets/facets_.$id.tsx

@@ -1,30 +1,26 @@
-import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.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';
+    TranslatableFormFieldWrapper
+} from '@/components/shared/translatable-form-field.js';
+import { Button } from '@/components/ui/button.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,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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';
@@ -33,24 +29,12 @@ import { createFacetDocument, facetDetailDocument, updateFacetDocument } from '.
 
 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,
-            ],
-        };
-    },
+    loader: detailPageRouteLoader({
+        queryDocument: facetDetailDocument,
+        breadcrumb(isNew, entity) {
+            return [{ path: '/facets', label: 'Facets' }, isNew ? <Trans>New facet</Trans> : entity?.name];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -60,9 +44,8 @@ export function FacetDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending } = useDetailPage({
-        queryDocument: addCustomFields(facetDetailDocument),
-        entityField: 'facet',
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        queryDocument: facetDetailDocument,
         createDocument: createFacetDocument,
         updateDocument: updateFacetDocument,
         setValuesForUpdate: entity => {
@@ -91,9 +74,9 @@ export function FacetDetailPage() {
             toast(i18n.t('Successfully updated facet'), {
                 position: 'top-right',
             });
-            form.reset(form.getValues());
+            resetForm();
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
@@ -104,15 +87,12 @@ export function FacetDetailPage() {
         },
     });
 
-    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 />
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
                             <Button
                                 type="submit"
@@ -121,75 +101,44 @@ export function FacetDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="side">
-                            <FormField
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="side">
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="isPrivate"
+                            label={<Trans>Private</Trans>}
+                            description={<Trans>Private facets are not visible in the shop</Trans>}
+                            render={({ field }) => (
+                                <Switch checked={field.value} onCheckedChange={field.onChange} />
+                            )}
+                        />
+                    </PageBlock>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <TranslatableFormFieldWrapper
                                 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>
-                                )}
+                                name="name"
+                                label={<Trans>Name</Trans>}
+                                render={({ field }) => <Input {...field} />}
                             />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="code"
+                                label={<Trans>Code</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="Facet" control={form.control} />
+                    {!creatingNewEntity && (
+                        <PageBlock column="main" title={<Trans>Facet values</Trans>}>
+                            <FacetValuesTable facetId={entity?.id} />
                         </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} />
-                        {!creatingNewEntity && (
-                            <PageBlock column="main" title={<Trans>Facet values</Trans>}>
-                                <FacetValuesTable facetId={entity?.id} />
-                            </PageBlock>
-                        )}
-                    </PageLayout>
-                </form>
-            </Form>
+                    )}
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 70 - 103
packages/dashboard/src/routes/_authenticated/_global-settings/global-settings.tsx

@@ -1,23 +1,19 @@
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { LanguageSelector } from '@/components/shared/language-selector.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
-import {
-    Form,
-    FormControl,
-    FormDescription,
-    FormField,
-    FormItem,
-    FormLabel
-} 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 {
     CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
@@ -69,7 +65,7 @@ export function GlobalSettingsPage() {
                 });
                 form.reset(form.getValues());
                 if (creatingNewEntity) {
-                    await navigate({ to: `../${data?.id}`, from: Route.id });
+                    await navigate({ to: `../$id`, params: { id: data.id } });
                 }
             } else {
                 toast(i18n.t('Failed to update global settings'), {
@@ -91,10 +87,9 @@ export function GlobalSettingsPage() {
             <PageTitle>
                 <Trans>Global settings</Trans>
             </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateSettings', 'UpdateGlobalSettings']}>
                             <Button
                                 type="submit"
@@ -103,96 +98,68 @@ export function GlobalSettingsPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 gap-4 items-start">
-                                <FormField
-                                    control={form.control}
-                                    name="availableLanguages"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Available languages</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <LanguageSelector
-                                                    value={field.value ?? []}
-                                                    onChange={field.onChange}
-                                                    multiple={true}
-                                                />
-                                            </FormControl>
-                                            <FormDescription>
-                                                    <Trans>
-                                                        Sets the languages that are available for all
-                                                        channels. Individual channels can then support a
-                                                        subset of these languages.
-                                                    </Trans>
-                                                </FormDescription>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="outOfStockThreshold"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Global out of stock threshold</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input
-                                                    value={field.value ?? []}
-                                                    onChange={e =>
-                                                        field.onChange(Number(e.target.valueAsNumber))
-                                                    }
-                                                    type="number"
-                                                />
-                                            </FormControl>
-                                            <FormDescription>
-                                                <Trans>
-                                                    Sets the stock level at which this a variant is considered
-                                                    to be out of stock. Using a negative value enables
-                                                    backorder support. Can be overridden by product variants.
-                                                </Trans>
-                                            </FormDescription>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="trackInventory"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Track inventory by default</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Switch
-                                                    checked={field.value}
-                                                    onCheckedChange={field.onChange}
-                                                />
-                                            </FormControl>
-                                            <FormDescription>
-                                                <Trans>
-                                                    When tracked, product variant stock levels will be
-                                                    automatically adjusted when sold. This setting can be
-                                                    overridden by individual product variants.
-                                                </Trans>
-                                            </FormDescription>
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                        </PageBlock>
-                        <CustomFieldsPageBlock
-                            column="main"
-                            entityType="GlobalSettings"
-                            control={form.control}
-                        />
-                    </PageLayout>
-                </form>
-            </Form>
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="availableLanguages"
+                                label={<Trans>Available languages</Trans>}
+                                description={
+                                    <Trans>
+                                        Sets the languages that are available for all channels. Individual
+                                        channels can then support a subset of these languages.
+                                    </Trans>
+                                }
+                                render={({ field }) => (
+                                    <LanguageSelector
+                                        value={field.value ?? []}
+                                        onChange={field.onChange}
+                                        multiple={true}
+                                    />
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="outOfStockThreshold"
+                                label={<Trans>Global out of stock threshold</Trans>}
+                                description={
+                                    <Trans>
+                                        Sets the stock level at which this a variant is considered to be out
+                                        of stock. Using a negative value enables backorder support. Can be
+                                        overridden by product variants.
+                                    </Trans>
+                                }
+                                render={({ field }) => (
+                                    <Input
+                                        value={field.value ?? []}
+                                        onChange={e => field.onChange(Number(e.target.valueAsNumber))}
+                                        type="number"
+                                    />
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="trackInventory"
+                                label={<Trans>Track inventory by default</Trans>}
+                                description={
+                                    <Trans>
+                                        When tracked, product variant stock levels will be automatically
+                                        adjusted when sold. This setting can be overridden by individual
+                                        product variants.
+                                    </Trans>
+                                }
+                                render={({ field }) => (
+                                    <Switch checked={field.value} onCheckedChange={field.onChange} />
+                                )}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="GlobalSettings" control={form.control} />
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 1 - 0
packages/dashboard/src/routes/_authenticated/_orders/orders.graphql.ts

@@ -290,6 +290,7 @@ export const orderDetailDocument = graphql(
         query GetOrder($id: ID!) {
             order(id: $id) {
                 ...OrderDetail
+                customFields
             }
         }
     `,

+ 5 - 9
packages/dashboard/src/routes/_authenticated/_orders/orders.tsx

@@ -1,13 +1,11 @@
 import { Money } from '@/components/data-display/money.js';
+import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { Badge } from '@/components/ui/badge.js';
 import { Button } from '@/components/ui/button.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import { ListPage } from '@/framework/page/list-page.js';
-import { ResultOf } from '@/graphql/graphql.js';
+import { Trans } from '@lingui/react/macro';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { orderListDocument } from './orders.graphql.js';
-import { DetailPageButton } from '@/components/shared/detail-page-button.js';
-import { Trans } from '@lingui/react/macro';
 
 export const Route = createFileRoute('/_authenticated/_orders/orders')({
     component: OrderListPage,
@@ -38,7 +36,7 @@ export function OrderListPage() {
                     filterOperator: 'OR',
                 };
             }}
-            listQuery={addCustomFields(orderListDocument)}
+            listQuery={orderListDocument}
             route={Route}
             customizeColumns={{
                 total: {
@@ -75,9 +73,7 @@ export function OrderListPage() {
                 customer: {
                     header: 'Customer',
                     cell: ({ cell }) => {
-                        const value = cell.getValue() as ResultOf<
-                            typeof orderListDocument
-                        >['orders']['items'][number]['customer'];
+                        const value = cell.getValue();
                         if (!value) {
                             return null;
                         }
@@ -93,7 +89,7 @@ export function OrderListPage() {
                 shippingLines: {
                     header: 'Shipping',
                     cell: ({ cell }) => {
-                        const value = cell.getValue() as ResultOf<typeof orderListDocument>['orders']['items'][number]['shippingLines'];
+                        const value = cell.getValue();
                         return <div>{value.map(line => line.shippingMethod.name).join(', ')}</div>;
                     },
                 },

+ 57 - 67
packages/dashboard/src/routes/_authenticated/_orders/orders_.$id.tsx

@@ -2,56 +2,46 @@ import { ErrorPage } from '@/components/shared/error-page.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Badge } from '@/components/ui/badge.js';
 import { Button } from '@/components/ui/button.js';
-import {
-    Form
-} from '@/components/ui/form.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { useDetailPage } from '@/framework/page/use-detail-page.js';
 import { Trans, useLingui } from '@lingui/react/macro';
-import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
+import { createFileRoute, Link } from '@tanstack/react-router';
 import { User } from 'lucide-react';
 import { toast } from 'sonner';
+import { OrderAddress } from './components/order-address.js';
 import { OrderHistoryContainer } from './components/order-history/order-history-container.js';
 import { OrderTable } from './components/order-table.js';
 import { OrderTaxSummary } from './components/order-tax-summary.js';
-import { orderDetailDocument } from './orders.graphql.js';
-import { OrderAddress } from './components/order-address.js';
 import { PaymentDetails } from './components/payment-details.js';
+import { orderDetailDocument } from './orders.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_orders/orders_/$id')({
     component: FacetDetailPage,
-    loader: async ({ context, params }) => {
-        const result = await context.queryClient.ensureQueryData(
-            getDetailQueryOptions(addCustomFields(orderDetailDocument), { id: params.id }),
-            { id: params.id },
-        );
-        if (!result.order) {
-            throw new Error(`Order with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [{ path: '/orders', label: 'Orders' }, result.order.code],
-        };
-    },
+    loader: detailPageRouteLoader({
+        queryDocument: orderDetailDocument,
+        breadcrumb(_isNew, entity) {
+            return [{ path: '/orders', label: 'Orders' }, entity?.code];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
 export function FacetDetailPage() {
     const params = Route.useParams();
-    const navigate = useNavigate();
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
-        queryDocument: addCustomFields(orderDetailDocument),
-        entityField: 'order',
-        // updateDocument: updateOrderDocument,
+    const { form, submitHandler, entity, isPending } = useDetailPage({
+        queryDocument: orderDetailDocument,
         setValuesForUpdate: entity => {
             return {
                 id: entity.id,
@@ -59,15 +49,12 @@ export function FacetDetailPage() {
             };
         },
         params: { id: params.id },
-        onSuccess: async data => {
-            toast(i18n.t('Successfully updated facet'), {
-                position: 'top-right',
-            });
+        onSuccess: async () => {
+            toast(i18n.t('Successfully updated order'));
             form.reset(form.getValues());
         },
         onError: err => {
-            toast(i18n.t('Failed to update facet'), {
-                position: 'top-right',
+            toast(i18n.t('Failed to update order'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -76,10 +63,9 @@ export function FacetDetailPage() {
     return (
         <Page>
             <PageTitle>{entity?.code ?? ''}</PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
                             <Button
                                 type="submit"
@@ -88,29 +74,30 @@ export function FacetDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="main">
-                            <OrderTable order={entity} />
-                        </PageBlock>
-                        <PageBlock column="main" title={<Trans>Tax summary</Trans>}>
-                            <OrderTaxSummary order={entity} />
-                        </PageBlock>
-                        <CustomFieldsPageBlock column="main" entityType="Order" control={form.control} />
-                        <PageBlock column="main" title={<Trans>Order history</Trans>}>
-                            <OrderHistoryContainer orderId={entity.id} />
-                        </PageBlock>
-                        <PageBlock column="side" title={<Trans>State</Trans>}>
-                            <Badge variant="outline">{entity?.state}</Badge>
-                        </PageBlock>
-                        <PageBlock column="side" title={<Trans>Customer</Trans>}>
-                            <Button variant="ghost" asChild>
-                                <Link to={`/customers/${entity?.customer?.id}`}>
-                                    <User className="w-4 h-4" />
-                                    {entity?.customer?.firstName} {entity?.customer?.lastName}
-                                </Link>
-                            </Button>
-                            <div className="mt-4 divide-y">
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="main">
+                        <OrderTable order={entity} />
+                    </PageBlock>
+                    <PageBlock column="main" title={<Trans>Tax summary</Trans>}>
+                        <OrderTaxSummary order={entity} />
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="Order" control={form.control} />
+                    <PageBlock column="main" title={<Trans>Order history</Trans>}>
+                        <OrderHistoryContainer orderId={entity.id} />
+                    </PageBlock>
+                    <PageBlock column="side" title={<Trans>State</Trans>}>
+                        <Badge variant="outline">{entity?.state}</Badge>
+                    </PageBlock>
+                    <PageBlock column="side" title={<Trans>Customer</Trans>}>
+                        <Button variant="ghost" asChild>
+                            <Link to={`/customers/${entity?.customer?.id}`}>
+                                <User className="w-4 h-4" />
+                                {entity?.customer?.firstName} {entity?.customer?.lastName}
+                            </Link>
+                        </Button>
+                        <div className="mt-4 divide-y">
                             {entity.shippingAddress && (
                                 <div className="pb-6">
                                     <div className="font-medium">
@@ -127,16 +114,19 @@ export function FacetDetailPage() {
                                     <OrderAddress address={entity.billingAddress} />
                                 </div>
                             )}
-                            </div>
-                        </PageBlock>
-                        <PageBlock column="side" title={<Trans>Payment details</Trans>}>
-                        {entity.payments.map(payment => (
-                            <PaymentDetails key={payment.id} payment={payment} currencyCode={entity.currencyCode} />
+                        </div>
+                    </PageBlock>
+                    <PageBlock column="side" title={<Trans>Payment details</Trans>}>
+                        {entity.payments?.map(payment => (
+                            <PaymentDetails
+                                key={payment.id}
+                                payment={payment}
+                                currencyCode={entity.currencyCode}
+                            />
                         ))}
-                        </PageBlock>
-                    </PageLayout>
-                </form>
-            </Form>
+                    </PageBlock>
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 3 - 3
packages/dashboard/src/routes/_authenticated/_payment-methods/components/payment-eligibility-checker-selector.tsx

@@ -26,8 +26,8 @@ export const paymentEligibilityCheckersDocument = graphql(
 );
 
 interface PaymentEligibilityCheckerSelectorProps {
-    value: ConfigurableOperationInputType | null;
-    onChange: (value: ConfigurableOperationInputType | null) => void;
+    value: ConfigurableOperationInputType | undefined;
+    onChange: (value: ConfigurableOperationInputType | undefined) => void;
 }
 
 export function PaymentEligibilityCheckerSelector({
@@ -61,7 +61,7 @@ export function PaymentEligibilityCheckerSelector({
     };
 
     const onOperationRemove = () => {
-        onChange(null);
+        onChange(undefined);
     };
 
     const checkerDef = checkers?.find(c => c.code === value?.code);

+ 3 - 3
packages/dashboard/src/routes/_authenticated/_payment-methods/components/payment-handler-selector.tsx

@@ -26,8 +26,8 @@ export const paymentHandlersDocument = graphql(
 );
 
 interface PaymentHandlerSelectorProps {
-    value: ConfigurableOperationInputType | null;
-    onChange: (value: ConfigurableOperationInputType | null) => void;
+    value: ConfigurableOperationInputType | undefined;
+    onChange: (value: ConfigurableOperationInputType | undefined) => void;
 }
 
 export function PaymentHandlerSelector({
@@ -61,7 +61,7 @@ export function PaymentHandlerSelector({
     };
 
     const onOperationRemove = () => {
-        onChange(null);
+        onChange(undefined);
     };
 
     const handlerDef = handlers?.find(h => h.code === value?.code);

+ 1 - 2
packages/dashboard/src/routes/_authenticated/_payment-methods/payment-methods.tsx

@@ -2,7 +2,6 @@ import { BooleanDisplayBadge } from '@/components/data-display/boolean.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@lingui/react/macro';
@@ -18,7 +17,7 @@ export const Route = createFileRoute('/_authenticated/_payment-methods/payment-m
 function PaymentMethodListPage() {
     return (
         <ListPage
-            listQuery={addCustomFields(paymentMethodListQuery)}
+            listQuery={paymentMethodListQuery}
             route={Route}
             title="Payment Methods"
             defaultVisibility={{

+ 44 - 95
packages/dashboard/src/routes/_authenticated/_payment-methods/payment-methods_.$id.tsx

@@ -1,22 +1,25 @@
-import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
+import { TranslatableFormFieldWrapper } 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 { Textarea } from '@/components/ui/textarea.js';
 import { NEW_ENTITY_PATH } from '@/constants.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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';
@@ -27,28 +30,15 @@ import {
     paymentMethodDetailDocument,
     updatePaymentMethodDocument,
 } from './payment-methods.graphql.js';
-import { Switch } from '@/components/ui/switch.js';
 
 export const Route = createFileRoute('/_authenticated/_payment-methods/payment-methods_/$id')({
     component: PaymentMethodDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(paymentMethodDetailDocument), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.paymentMethod) {
-            throw new Error(`Payment method with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
-                { path: '/payment-methods', label: 'Payment methods' },
-                isNew ? <Trans>New payment method</Trans> : result.paymentMethod.name,
-            ],
-        };
-    },
+    loader: detailPageRouteLoader({
+        queryDocument: paymentMethodDetailDocument,
+        breadcrumb(_isNew, entity) {
+            return [{ path: '/payment-methods', label: 'Payment methods' }, entity?.name];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -58,9 +48,8 @@ export function PaymentMethodDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
-        queryDocument: addCustomFields(paymentMethodDetailDocument),
-        entityField: 'paymentMethod',
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        queryDocument: paymentMethodDetailDocument,
         createDocument: createPaymentMethodDocument,
         updateDocument: updatePaymentMethodDocument,
         setValuesForUpdate: entity => {
@@ -96,17 +85,14 @@ export function PaymentMethodDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast(i18n.t('Successfully updated payment method'), {
-                position: 'top-right',
-            });
-            form.reset(form.getValues());
+            toast.success(i18n.t('Successfully updated payment method'));
+            resetForm();
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
-            toast(i18n.t('Failed to update payment method'), {
-                position: 'top-right',
+            toast.error(i18n.t('Failed to update payment method'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -117,10 +103,9 @@ export function PaymentMethodDetailPage() {
             <PageTitle>
                 {creatingNewEntity ? <Trans>New payment method</Trans> : (entity?.name ?? '')}
             </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <ContentLanguageSelector />
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdatePaymentMethod']}>
                             <Button
                                 type="submit"
@@ -129,77 +114,42 @@ export function PaymentMethodDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
                     <PageBlock column="side">
-                            <FormField
+                        <FormFieldWrapper
                                 control={form.control}
                                 name="enabled"
-                                render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Enabled</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
-                                        </FormControl>
-                                        <FormMessage />
-                                    </FormItem>
-                                )}
+                                label={<Trans>Enabled</Trans>}
+                                render={({ field }) => <Switch checked={field.value ?? false} onCheckedChange={field.onChange} />}
                             />
                         </PageBlock>
                         <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 md:gap-4 mb-4">
-                                <TranslatableFormField
+                            <DetailFormGrid>
+                                <TranslatableFormFieldWrapper
                                     control={form.control}
                                     name="name"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage /> 
-                                        </FormItem>
-                                    )}
+                                    label={<Trans>Name</Trans>}
+                                    render={({ field }) => <Input {...field} />}
                                 />
-                                <FormField
+                                <FormFieldWrapper
                                     control={form.control}
                                     name="code"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Code</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
+                                    label={<Trans>Code</Trans>}
+                                    render={({ field }) => <Input {...field} />}
                                 />
-                            </div>
-                            <TranslatableFormField
+                            </DetailFormGrid>
+                            <TranslatableFormFieldWrapper
                                 control={form.control}
                                 name="description"
-                                render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Description</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Textarea placeholder="" {...field} />
-                                        </FormControl>
-                                        <FormMessage />
-                                    </FormItem>
-                                )}
+                                label={<Trans>Description</Trans>}
+                                render={({ field }) => <Textarea {...field} />}
                             />
                         </PageBlock>
                         <CustomFieldsPageBlock column="main" entityType="PaymentMethod" control={form.control} />
                         <PageBlock column="main" title={<Trans>Payment eligibility checker</Trans>}>
-                            <FormField
+                            <FormFieldWrapper
                                 control={form.control}
                                 name="checker"
                                 render={({ field }) => (
@@ -211,7 +161,7 @@ export function PaymentMethodDetailPage() {
                             />
                         </PageBlock>
                         <PageBlock column="main" title={<Trans>Calculator</Trans>}>
-                            <FormField
+                            <FormFieldWrapper
                                 control={form.control}
                                 name="handler"
                                 render={({ field }) => (
@@ -223,8 +173,7 @@ export function PaymentMethodDetailPage() {
                             />
                         </PageBlock>
                     </PageLayout>
-                </form>
-            </Form>
-        </Page>
+                </PageDetailForm>
+            </Page>
     );
 }

+ 2 - 2
packages/dashboard/src/routes/_authenticated/_product-variants/components/variant-price-detail.tsx

@@ -25,9 +25,9 @@ const taxRatesDocument = graphql(`
 `);
 interface VariantPriceDetailProps {
     priceIncludesTax: boolean;
-    price: number;
+    price: number | undefined;
     currencyCode: string;
-    taxCategoryId: string;
+    taxCategoryId: string | undefined;
 }
 
 export function VariantPriceDetail({

+ 2 - 0
packages/dashboard/src/routes/_authenticated/_product-variants/product-variants.graphql.ts

@@ -51,9 +51,11 @@ export const productVariantDetailDocument = graphql(
                 }
                 facetValues {
                     id
+                    code
                     name
                     facet {
                         id
+                        code
                         name
                     }
                 }

+ 196 - 266
packages/dashboard/src/routes/_authenticated/_product-variants/product-variants_.$id.tsx

@@ -1,36 +1,43 @@
 import { MoneyInput } from '@/components/data-input/money-input.js';
-import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
+import { AssignedFacetValues } from '@/components/shared/assigned-facet-values.js';
 import { EntityAssets } from '@/components/shared/entity-assets.js';
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { TaxCategorySelector } from '@/components/shared/tax-category-selector.js';
-import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
+import {
+    TranslatableFormFieldWrapper
+} from '@/components/shared/translatable-form-field.js';
 import { Button } from '@/components/ui/button.js';
 import {
-    Form,
     FormControl,
     FormDescription,
     FormField,
     FormItem,
     FormLabel,
-    FormMessage,
+    FormMessage
 } from '@/components/ui/form.js';
 import { Input } from '@/components/ui/input.js';
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.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,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { useDetailPage } from '@/framework/page/use-detail-page.js';
+import { useChannel } from '@/hooks/use-channel.js';
 import { Trans, useLingui } from '@lingui/react/macro';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { Fragment } from 'react/jsx-runtime';
 import { toast } from 'sonner';
 import { VariantPriceDetail } from './components/variant-price-detail.js';
 import {
@@ -38,29 +45,15 @@ import {
     productVariantDetailDocument,
     updateProductVariantDocument,
 } from './product-variants.graphql.js';
-import { Fragment } from 'react/jsx-runtime';
-import { AssignedFacetValues } from '@/components/shared/assigned-facet-values.js';
 
 export const Route = createFileRoute('/_authenticated/_product-variants/product-variants_/$id')({
     component: ProductVariantDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(productVariantDetailDocument), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.productVariant) {
-            throw new Error(`Product with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
-                { path: '/product-variants', label: 'Product variants' },
-                isNew ? <Trans>New product variant</Trans> : result.productVariant.name,
-            ],
-        };
-    },
+    loader: detailPageRouteLoader({
+        queryDocument: productVariantDetailDocument,
+        breadcrumb(_isNew, entity) {
+            return [{ path: '/product-variants', label: 'Product variants' }, entity?.name];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -69,10 +62,10 @@ export function ProductVariantDetailPage() {
     const navigate = useNavigate();
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
+    const { activeChannel } = useChannel();
 
-    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
-        queryDocument: addCustomFields(productVariantDetailDocument),
-        entityField: 'productVariant',
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        queryDocument: productVariantDetailDocument,
         createDocument: createProductVariantDocument,
         updateDocument: updateProductVariantDocument,
         setValuesForUpdate: entity => {
@@ -103,17 +96,14 @@ export function ProductVariantDetailPage() {
         },
         params: { id: params.id },
         onSuccess: data => {
-            toast(i18n.t('Successfully updated product'), {
-                position: 'top-right',
-            });
-            form.reset(form.getValues());
+            toast.success(i18n.t('Successfully updated product'));
+            resetForm();
             if (creatingNewEntity) {
                 navigate({ to: `../${data?.[0]?.id}`, from: Route.id });
             }
         },
         onError: err => {
-            toast(i18n.t('Failed to update product'), {
-                position: 'top-right',
+            toast.error(i18n.t('Failed to update product'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -126,10 +116,9 @@ export function ProductVariantDetailPage() {
             <PageTitle>
                 {creatingNewEntity ? <Trans>New product variant</Trans> : (entity?.name ?? '')}
             </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <ContentLanguageSelector />
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
                             <Button
                                 type="submit"
@@ -138,269 +127,210 @@ export function ProductVariantDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="side">
-                            <FormField
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="side">
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="enabled"
+                            label={<Trans>Enabled</Trans>}
+                            description={<Trans>When enabled, a product is available in the shop</Trans>}
+                            render={({ field }) => (
+                                <Switch checked={field.value} onCheckedChange={field.onChange} />
+                            )}
+                        />
+                    </PageBlock>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <TranslatableFormFieldWrapper
+                                control={form.control}
+                                name="name"
+                                label={<Trans>Product name</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="sku"
+                                label={<Trans>SKU</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="ProductVariant" control={form.control} />
+
+                    <PageBlock column="main" title={<Trans>Price and tax</Trans>}>
+                        <div className="grid grid-cols-2 gap-4 items-start">
+                            <FormFieldWrapper
                                 control={form.control}
-                                name="enabled"
+                                name="taxCategoryId"
+                                label={<Trans>Tax category</Trans>}
                                 render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Enabled</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Switch checked={field.value} onCheckedChange={field.onChange} />
-                                        </FormControl>
-                                        <FormDescription>
-                                            <Trans>When enabled, a product is available in the shop</Trans>
-                                        </FormDescription>
-                                        <FormMessage />
-                                    </FormItem>
+                                    <TaxCategorySelector value={field.value} onChange={field.onChange} />
                                 )}
                             />
-                        </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>Product name</Trans>
-                                                </FormLabel>
-                                                <FormControl>
-                                                    <Input placeholder="" {...field} />
-                                                </FormControl>
-                                                <FormMessage />
-                                            </FormItem>
-                                        )}
-                                    />
-                                </div>
-                                <div className="w-1/2">
-                                    <FormField
-                                        control={form.control}
-                                        name="sku"
-                                        render={({ field }) => (
-                                            <FormItem>
-                                                <FormLabel>
-                                                    <Trans>SKU</Trans>
-                                                </FormLabel>
-                                                <FormControl>
-                                                    <Input placeholder="" {...field} />
-                                                </FormControl>
-                                                <FormMessage />
-                                            </FormItem>
-                                        )}
-                                    />
-                                </div>
-                            </div>
-                        </PageBlock>
-                        <CustomFieldsPageBlock
-                            column="main"
-                            entityType="ProductVariant"
-                            control={form.control}
-                        />
 
-                        <PageBlock column="main" title={<Trans>Price and tax</Trans>}>
-                            <div className="grid grid-cols-2 gap-4 items-start">
-                                <FormField
+                            <div>
+                                <FormFieldWrapper
                                     control={form.control}
-                                    name="taxCategoryId"
+                                    name="price"
+                                    label={<Trans>Price</Trans>}
                                     render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Tax category</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <TaxCategorySelector {...field} />
-                                            </FormControl>
-                                        </FormItem>
+                                        <MoneyInput {...field} currency={entity?.currencyCode} />
                                     )}
                                 />
-
-                                <div>
-                                    <FormField
+                                <VariantPriceDetail
+                                    priceIncludesTax={activeChannel?.pricesIncludeTax ?? false}
+                                    price={price}
+                                    currencyCode={entity?.currencyCode}
+                                    taxCategoryId={taxCategoryId}
+                                />
+                            </div>
+                        </div>
+                    </PageBlock>
+                    <PageBlock column="main" title={<Trans>Stock</Trans>}>
+                        <DetailFormGrid>
+                            {entity.stockLevels.map((stockLevel, index) => (
+                                <Fragment key={stockLevel.id}>
+                                    <FormFieldWrapper
                                         control={form.control}
-                                        name="price"
+                                        name={`stockLevels.${index}.stockOnHand`}
                                         render={({ field }) => (
                                             <FormItem>
                                                 <FormLabel>
-                                                    <Trans>Price</Trans>
+                                                    <Trans>Stock level</Trans>
                                                 </FormLabel>
                                                 <FormControl>
-                                                    <MoneyInput {...field} currency={entity?.currencyCode} />
+                                                    <Input type="number" {...field} />
                                                 </FormControl>
                                             </FormItem>
                                         )}
                                     />
-                                    <VariantPriceDetail
-                                        priceIncludesTax={entity.priceIncludesTax}
-                                        price={price}
-                                        currencyCode={entity.currencyCode}
-                                        taxCategoryId={taxCategoryId}
-                                    />
-                                </div>
-                            </div>
-                        </PageBlock>
-                        <PageBlock column="main" title={<Trans>Stock</Trans>}>
-                            <div className="grid grid-cols-2 gap-4 items-start">
-                                {entity.stockLevels.map((stockLevel, index) => (
-                                    <Fragment key={stockLevel.id}>
-                                        <FormField
-                                            control={form.control}
-                                            name={`stockLevels.${index}.stockOnHand`}
-                                            render={({ field }) => (
-                                                <FormItem>
-                                                    <FormLabel>
-                                                        <Trans>Stock level</Trans>
-                                                    </FormLabel>
-                                                    <FormControl>
-                                                        <Input type="number" {...field} />
-                                                    </FormControl>
-                                                </FormItem>
-                                            )}
-                                        />
-                                        <div>
-                                            <FormItem>
-                                                <FormLabel>
-                                                    <Trans>Allocated</Trans>
-                                                </FormLabel>
-                                                <div className="text-sm pt-1.5">{stockLevel.stockAllocated}</div>
-                                            </FormItem>
-                                        </div>
-                                    </Fragment>
-                                ))}
-
-                                <FormField
-                                    control={form.control}
-                                    name="trackInventory"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Track inventory</Trans>
-                                            </FormLabel>
-                                            <Select onValueChange={field.onChange} value={field.value}>
-                                                <FormControl>
-                                                    <SelectTrigger className="">
-                                                        <SelectValue placeholder="Track inventory" />
-                                                    </SelectTrigger>
-                                                </FormControl>
-                                                <SelectContent>
-                                                    <SelectItem value="INHERIT">
-                                                        <Trans>Inherit from global settings</Trans>
-                                                    </SelectItem>
-                                                    <SelectItem value="TRUE">
-                                                        <Trans>Track</Trans>
-                                                    </SelectItem>
-                                                    <SelectItem value="FALSE">
-                                                        <Trans>Do not track</Trans>
-                                                    </SelectItem>
-                                                </SelectContent>
-                                            </Select>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="outOfStockThreshold"
-                                    render={({ field }) => (
+                                    <div>
                                         <FormItem>
                                             <FormLabel>
-                                                <Trans>Out-of-stock threshold</Trans>
+                                                <Trans>Allocated</Trans>
                                             </FormLabel>
-                                            <FormControl>
-                                                <Input type="number" {...field} />
-                                            </FormControl>
-                                            <FormDescription>
-                                                <Trans>
-                                                    Sets the stock level at which this variant is considered
-                                                    to be out of stock. Using a negative value enables
-                                                    backorder support.
-                                                </Trans>
-                                            </FormDescription>
+                                            <div className="text-sm pt-1.5">{stockLevel.stockAllocated}</div>
                                         </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="useGlobalOutOfStockThreshold"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Use global out-of-stock threshold</Trans>
-                                            </FormLabel>
+                                    </div>
+                                </Fragment>
+                            ))}
+
+                            <FormField
+                                control={form.control}
+                                name="trackInventory"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>
+                                            <Trans>Track inventory</Trans>
+                                        </FormLabel>
+                                        <Select onValueChange={field.onChange} value={field.value}>
                                             <FormControl>
-                                                <Switch
-                                                    checked={field.value}
-                                                    onCheckedChange={field.onChange}
-                                                />
+                                                <SelectTrigger className="">
+                                                    <SelectValue placeholder="Track inventory" />
+                                                </SelectTrigger>
                                             </FormControl>
-                                            <FormDescription>
-                                                <Trans>
-                                                    Sets the stock level at which this variant is considered
-                                                    to be out of stock. Using a negative value enables
-                                                    backorder support.
-                                                </Trans>
-                                            </FormDescription>
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                        </PageBlock>
-
-                        <PageBlock column="side">
+                                            <SelectContent>
+                                                <SelectItem value="INHERIT">
+                                                    <Trans>Inherit from global settings</Trans>
+                                                </SelectItem>
+                                                <SelectItem value="TRUE">
+                                                    <Trans>Track</Trans>
+                                                </SelectItem>
+                                                <SelectItem value="FALSE">
+                                                    <Trans>Do not track</Trans>
+                                                </SelectItem>
+                                            </SelectContent>
+                                        </Select>
+                                    </FormItem>
+                                )}
+                            />
                             <FormField
                                 control={form.control}
-                                name="facetValueIds"
+                                name="outOfStockThreshold"
                                 render={({ field }) => (
                                     <FormItem>
                                         <FormLabel>
-                                            <Trans>Facet values</Trans>
+                                            <Trans>Out-of-stock threshold</Trans>
                                         </FormLabel>
                                         <FormControl>
-                                            <AssignedFacetValues
-                                                facetValues={entity?.facetValues ?? []}
-                                                {...field}
-                                            />
+                                            <Input type="number" {...field} />
                                         </FormControl>
-                                        <FormMessage />
+                                        <FormDescription>
+                                            <Trans>
+                                                Sets the stock level at which this variant is considered to be
+                                                out of stock. Using a negative value enables backorder
+                                                support.
+                                            </Trans>
+                                        </FormDescription>
                                     </FormItem>
                                 )}
                             />
-                        </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>
-                    </PageLayout>
-                </form>
-            </Form>
+                            <FormField
+                                control={form.control}
+                                name="useGlobalOutOfStockThreshold"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>
+                                            <Trans>Use global out-of-stock threshold</Trans>
+                                        </FormLabel>
+                                        <FormControl>
+                                            <Switch checked={field.value} onCheckedChange={field.onChange} />
+                                        </FormControl>
+                                        <FormDescription>
+                                            <Trans>
+                                                Sets the stock level at which this variant is considered to be
+                                                out of stock. Using a negative value enables backorder
+                                                support.
+                                            </Trans>
+                                        </FormDescription>
+                                    </FormItem>
+                                )}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+
+                    <PageBlock column="side">
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="facetValueIds"
+                            label={<Trans>Facet values</Trans>}
+                            render={({ field }) => (
+                                <AssignedFacetValues facetValues={entity?.facetValues ?? []} {...field} />
+                            )}
+                        />
+                    </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>
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 16 - 16
packages/dashboard/src/routes/_authenticated/_products/products.tsx

@@ -1,13 +1,12 @@
+import { DetailPageButton } from '@/components/shared/detail-page-button.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
+import { Trans } from '@lingui/react/macro';
 import { createFileRoute, Link } from '@tanstack/react-router';
-import { productListDocument } from './products.graphql.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
 import { PlusIcon } from 'lucide-react';
-import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { Trans } from '@lingui/react/macro';
-import { DetailPageButton } from '@/components/shared/detail-page-button.js';
+import { productListDocument } from './products.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_products/products')({
     component: ProductListPage,
@@ -29,19 +28,20 @@ export function ProductListPage() {
                     name: { contains: searchTerm },
                 };
             }}
-            listQuery={addCustomFields(productListDocument)}
+            listQuery={productListDocument}
             route={Route}
         >
             <PageActionBar>
-                <div></div>
-                <PermissionGuard requires={['CreateProduct', 'CreateCatalog']}>
-                    <Button asChild>
-                        <Link to="./new">
-                            <PlusIcon className="mr-2 h-4 w-4" />
-                            New Product
-                        </Link>
-                    </Button>
-                </PermissionGuard>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateProduct', 'CreateCatalog']}>
+                        <Button asChild>
+                            <Link to="./new">
+                                <PlusIcon className="mr-2 h-4 w-4" />
+                                New Product
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 110 - 180
packages/dashboard/src/routes/_authenticated/_products/products_.$id.tsx

@@ -1,59 +1,46 @@
-import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
 import { AssignedFacetValues } from '@/components/shared/assigned-facet-values.js';
 import { EntityAssets } from '@/components/shared/entity-assets.js';
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
+import { TranslatableFormFieldWrapper } 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 { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from '@/components/ui/form.js';
 import { Input } from '@/components/ui/input.js';
 import { Switch } from '@/components/ui/switch.js';
 import { Textarea } from '@/components/ui/textarea.js';
 import { NEW_ENTITY_PATH } from '@/constants.js';
 import {
+    CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
-    CustomFieldsPageBlock,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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 { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
 import { ProductVariantsTable } from './components/product-variants-table.js';
 import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 
 export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
     component: ProductDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(productDetailDocument), { id: params.id }), { id: params.id },
-              );
-        if (!isNew && !result.product) {
-            throw new Error(`Product with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
+    loader: detailPageRouteLoader({
+        queryDocument: productDetailDocument,
+        breadcrumb(isNew, entity) {
+            return [
                 { path: '/products', label: 'Products' },
-                isNew ? <Trans>New product</Trans> : result.product.name,
-            ],
-        };
-    },
+                isNew ? <Trans>New product</Trans> : entity?.name,
+            ];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -63,9 +50,8 @@ export function ProductDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
-        queryDocument: addCustomFields(productDetailDocument),
-        entityField: 'product',
+    const { form, submitHandler, entity, isPending, refreshEntity, resetForm } = useDetailPage({
+        queryDocument: productDetailDocument,
         createDocument: createProductDocument,
         updateDocument: updateProductDocument,
         setValuesForUpdate: entity => {
@@ -88,17 +74,14 @@ export function ProductDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast(i18n.t('Successfully updated product'), {
-                position: 'top-right',
-            });
-            form.reset(form.getValues());
+            toast.success(i18n.t('Successfully updated product'));
+            resetForm();
             if (creatingNewEntity) {
                 await navigate({ to: `../${data.id}`, from: Route.id });
             }
         },
         onError: err => {
-            toast(i18n.t('Failed to update product'), {
-                position: 'top-right',
+            toast.error(i18n.t('Failed to update product'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -107,10 +90,9 @@ export function ProductDetailPage() {
     return (
         <Page>
             <PageTitle>{creatingNewEntity ? <Trans>New product</Trans> : (entity?.name ?? '')}</PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <ContentLanguageSelector />
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
                             <Button
                                 type="submit"
@@ -119,151 +101,99 @@ export function ProductDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="side">
-                            <FormField
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="side">
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="enabled"
+                            label={<Trans>Enabled</Trans>}
+                            description={<Trans>When enabled, a product is available in the shop</Trans>}
+                            render={({ field }) => (
+                                <Switch checked={field.value} onCheckedChange={field.onChange} />
+                            )}
+                        />
+                    </PageBlock>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <TranslatableFormFieldWrapper
+                                control={form.control}
+                                name="name"
+                                label={<Trans>Product name</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                            <TranslatableFormFieldWrapper
                                 control={form.control}
-                                name="enabled"
-                                render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Enabled</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Switch checked={field.value} onCheckedChange={field.onChange} />
-                                        </FormControl>
-                                        <FormDescription>
-                                            <Trans>When enabled, a product is available in the shop</Trans>
-                                        </FormDescription>
-                                        <FormMessage />
-                                    </FormItem>
-                                )}
+                                name="slug"
+                                label={<Trans>Slug</Trans>}
+                                render={({ field }) => <Input {...field} />}
                             />
+                        </DetailFormGrid>
+
+                        <TranslatableFormFieldWrapper
+                            control={form.control}
+                            name="description"
+                            label={<Trans>Description</Trans>}
+                            render={({ field }) => <Textarea className="resize-none" {...field} />}
+                        />
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="Product" control={form.control} />
+                    {entity && entity.variantList.totalItems > 0 && (
+                        <PageBlock column="main">
+                            <ProductVariantsTable productId={params.id} />
                         </PageBlock>
+                    )}
+                    {entity && entity.variantList.totalItems === 0 && (
                         <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>Product name</Trans>
-                                                </FormLabel>
-                                                <FormControl>
-                                                    <Input placeholder="" {...field} />
-                                                </FormControl>
-                                                <FormDescription></FormDescription>
-                                                <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>
-                                                <FormDescription></FormDescription>
-                                                <FormMessage />
-                                            </FormItem>
-                                        )}
-                                    />
-                                </div>
-                            </div>
-                            <TranslatableFormField
-                                control={form.control}
-                                name="description"
-                                render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Description</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Textarea className="resize-none" {...field} />
-                                        </FormControl>
-                                        <FormDescription></FormDescription>
-                                        <FormMessage />
-                                    </FormItem>
-                                )}
+                            <CreateProductVariantsDialog
+                                productId={entity.id}
+                                productName={entity.name}
+                                onSuccess={() => {
+                                    refreshEntity();
+                                }}
                             />
                         </PageBlock>
-                        <CustomFieldsPageBlock column="main" entityType="Product" control={form.control} />
-                        {entity && entity.variantList.totalItems > 0 && (
-                            <PageBlock column="main">
-                                <ProductVariantsTable productId={params.id} />
-                            </PageBlock>
-                        )}
-                        {entity && entity.variantList.totalItems === 0 && (
-                            <PageBlock column="main">
-                                <CreateProductVariantsDialog
-                                    productId={entity.id}
-                                    productName={entity.name}
-                                    onSuccess={() => {
-                                        refreshEntity();
+                    )}
+                    <PageBlock column="side">
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="facetValueIds"
+                            label={<Trans>Facet values</Trans>}
+                            render={({ field }) => (
+                                <AssignedFacetValues facetValues={entity?.facetValues ?? []} {...field} />
+                            )}
+                        />
+                    </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 ?? undefined, {
+                                            shouldDirty: true,
+                                            shouldValidate: true,
+                                        });
+                                        form.setValue('assetIds', value.assetIds ?? [], {
+                                            shouldDirty: true,
+                                            shouldValidate: true,
+                                        });
                                     }}
                                 />
-                            </PageBlock>
-                        )}
-                        <PageBlock column="side">
-                            <FormField
-                                control={form.control}
-                                name="facetValueIds"
-                                render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Facet values</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <AssignedFacetValues
-                                                facetValues={entity?.facetValues ?? []}
-                                                {...field}
-                                            />
-                                        </FormControl>
-                                        <FormMessage />
-                                    </FormItem>
-                                )}
-                            />
-                        </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>
-                    </PageLayout>
-                </form>
-            </Form>
+                            </FormControl>
+                            <FormDescription></FormDescription>
+                            <FormMessage />
+                        </FormItem>
+                    </PageBlock>
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 41 - 87
packages/dashboard/src/routes/_authenticated/_profile/profile.tsx

@@ -1,19 +1,15 @@
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { Button } from '@/components/ui/button.js';
-import {
-    Form,
-    FormControl,
-    FormField,
-    FormItem,
-    FormLabel,
-    FormMessage
-} from '@/components/ui/form.js';
 import { Input } from '@/components/ui/input.js';
 import {
     CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
@@ -81,91 +77,49 @@ export function ProfilePage() {
             <PageTitle>
                 <Trans>Profile</Trans>
             </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <Button
                             type="submit"
                             disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
                         >
                             <Trans>Update</Trans>
                         </Button>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 gap-4">
-                                <FormField
-                                    control={form.control}
-                                    name="firstName"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>First name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="lastName"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Last name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="emailAddress"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Email Address or identifier</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-
-                                <FormField
-                                    control={form.control}
-                                    name="password"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Password</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" type="password" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                        </PageBlock>
-                        <CustomFieldsPageBlock
-                            column="main"
-                            entityType="Administrator"
-                            control={form.control}
-                        />
-                    </PageLayout>
-                </form>
-            </Form>
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="firstName"
+                                label={<Trans>First name</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="lastName"
+                                label={<Trans>Last name</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="emailAddress"
+                                label={<Trans>Email Address or identifier</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="password"
+                                label={<Trans>Password</Trans>}
+                                render={({ field }) => <Input type="password" {...field} />}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="Administrator" control={form.control} />
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 9 - 8
packages/dashboard/src/routes/_authenticated/_promotions/promotions.tsx

@@ -1,7 +1,7 @@
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@lingui/react/macro';
 import { Link, createFileRoute } from '@tanstack/react-router';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { PlusIcon } from 'lucide-react';
 import { Button } from '@/components/ui/button.js';
@@ -45,15 +45,16 @@ function PromotionListPage() {
             }}
         >
             <PageActionBar>
-                <div></div>
-                <PermissionGuard requires={['CreatePromotion']}>
-                    <Button asChild>
-                        <Link to="./new">
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreatePromotion']}>
+                        <Button asChild>
+                            <Link to="./new">
                             <PlusIcon className="mr-2 h-4 w-4" />
                             <Trans>New Promotion</Trans>
-                        </Link>
-                    </Button>
-                </PermissionGuard>
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 120 - 209
packages/dashboard/src/routes/_authenticated/_promotions/promotions_.$id.tsx

@@ -1,72 +1,50 @@
-import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
-import { EntityAssets } from '@/components/shared/entity-assets.js';
+import { DateTimeInput } from '@/components/data-input/datetime-input.js';
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.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';
+    TranslatableFormFieldWrapper
+} from '@/components/shared/translatable-form-field.js';
+import { Button } from '@/components/ui/button.js';
 import { Input } from '@/components/ui/input.js';
 import { Switch } from '@/components/ui/switch.js';
 import { Textarea } from '@/components/ui/textarea.js';
 import { NEW_ENTITY_PATH } from '@/constants.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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 { PromotionActionsSelector } from './components/promotion-actions-selector.js';
+import { PromotionConditionsSelector } from './components/promotion-conditions-selector.js';
 import {
-    collectionDetailDocument,
-    createCollectionDocument,
-    updateCollectionDocument,
-} from './collections.graphql.js';
-import { CollectionContentsTable } from './components/collection-contents-table.js';
-import { CollectionFiltersSelect } from './components/collection-filters-select.js';
-import { CollectionContentsPreviewTable } from './components/collection-contents-preview-table.js';
-import {
-    promotionDetailDocument,
     createPromotionDocument,
+    promotionDetailDocument,
     updatePromotionDocument,
 } from './promotions.graphql.js';
-import { PromotionConditionsSelector } from './components/promotion-conditions-selector.js';
-import { PromotionActionsSelector } from './components/promotion-actions-selector.js';
-import { DateTimeInput } from '@/components/data-input/datetime-input.js';
 
 export const Route = createFileRoute('/_authenticated/_promotions/promotions_/$id')({
     component: PromotionDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(promotionDetailDocument), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.promotion) {
-            throw new Error(`Promotion with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
+    loader: detailPageRouteLoader({
+        queryDocument: promotionDetailDocument,
+        breadcrumb(isNew, entity) {
+            return [
                 { path: '/promotions', label: 'Promotions' },
-                isNew ? <Trans>New promotion</Trans> : result.promotion.name,
-            ],
-        };
-    },
+                isNew ? <Trans>New promotion</Trans> : entity?.name,
+            ];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -76,9 +54,8 @@ export function PromotionDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
-        queryDocument: addCustomFields(promotionDetailDocument),
-        entityField: 'promotion',
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        queryDocument: promotionDetailDocument,
         createDocument: createPromotionDocument,
         transformCreateInput: values => {
             return {
@@ -118,17 +95,16 @@ export function PromotionDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast(i18n.t('Successfully updated promotion'), {
-                position: 'top-right',
-            });
-            form.reset(form.getValues());
-            if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+            if (data.__typename === 'Promotion') {
+                toast.success(i18n.t('Successfully updated promotion'));
+                resetForm();
+                if (creatingNewEntity) {
+                    await navigate({ to: `../${data.id}`, from: Route.id });
+                }
             }
         },
         onError: err => {
-            toast(i18n.t('Failed to update promotion'), {
-                position: 'top-right',
+            toast.error(i18n.t('Failed to update promotion'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -137,10 +113,9 @@ export function PromotionDetailPage() {
     return (
         <Page>
             <PageTitle>{creatingNewEntity ? <Trans>New promotion</Trans> : (entity?.name ?? '')}</PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <ContentLanguageSelector />
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdatePromotion']}>
                             <Button
                                 type="submit"
@@ -149,170 +124,106 @@ export function PromotionDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="side">
-                            <FormField
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="side">
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="enabled"
+                            label={<Trans>Enabled</Trans>}
+                            description={<Trans>When enabled, a promotion is available in the shop</Trans>}
+                            render={({ field }) => (
+                                <Switch checked={field.value} onCheckedChange={field.onChange} />
+                            )}
+                        />
+                    </PageBlock>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <TranslatableFormFieldWrapper
                                 control={form.control}
-                                name="enabled"
-                                render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Enabled</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Switch checked={field.value} onCheckedChange={field.onChange} />
-                                        </FormControl>
-                                        <FormDescription>
-                                            <Trans>
-                                                If a promotion is enabled, it will be applied to orders in the
-                                                shop
-                                            </Trans>
-                                        </FormDescription>
-                                        <FormMessage />
-                                    </FormItem>
-                                )}
+                                name="name"
+                                label={<Trans>Name</Trans>}
+                                render={({ field }) => <Input {...field} />}
                             />
-                        </PageBlock>
-                        <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 md:gap-4 mb-4">
-                                <TranslatableFormField
-                                    control={form.control}
-                                    name="name"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                            <TranslatableFormField
+                            <div></div>
+                        </DetailFormGrid>
+                        <TranslatableFormFieldWrapper
+                            control={form.control}
+                            name="description"
+                            label={<Trans>Description</Trans>}
+                            render={({ field }) => <Textarea {...field} />}
+                        />
+                        <DetailFormGrid>
+                            <FormFieldWrapper
                                 control={form.control}
-                                name="description"
+                                name="startsAt"
+                                label={<Trans>Starts at</Trans>}
                                 render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Description</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Textarea placeholder="" {...field} />
-                                        </FormControl>
-                                        <FormMessage />
-                                    </FormItem>
+                                    <DateTimeInput
+                                        value={field.value}
+                                        onChange={value => field.onChange(value.toISOString())}
+                                    />
                                 )}
                             />
-                            <div className="md:grid md:grid-cols-2 md:gap-4 my-4">
-                                <FormField
-                                    control={form.control}
-                                    name="startsAt"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Starts at</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <DateTimeInput value={field.value} onChange={value => field.onChange(value.toISOString())} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}  
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="endsAt"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Ends at</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <DateTimeInput value={field.value} onChange={value => field.onChange(value.toISOString())} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="couponCode"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Coupon code</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="perCustomerUsageLimit"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Per customer usage limit</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" type="number" value={field.value} onChange={e => field.onChange(e.target.valueAsNumber)} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="usageLimit"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Usage limit</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" type="number" value={field.value} onChange={e => field.onChange(e.target.valueAsNumber)} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                        </PageBlock>
-                        <CustomFieldsPageBlock column="main" entityType="Promotion" control={form.control} />
-                        <PageBlock column="main" title={<Trans>Conditions</Trans>}>
-                            <FormField
+                            <FormFieldWrapper
                                 control={form.control}
-                                name="conditions"
+                                name="endsAt"
+                                label={<Trans>Ends at</Trans>}
                                 render={({ field }) => (
-                                    <PromotionConditionsSelector
-                                        value={field.value ?? []}
-                                        onChange={field.onChange}
+                                    <DateTimeInput
+                                        value={field.value}
+                                        onChange={value => field.onChange(value.toISOString())}
                                     />
                                 )}
                             />
-                        </PageBlock>
-                        <PageBlock column="main" title={<Trans>Actions</Trans>}>
-                            <FormField
+                            <FormFieldWrapper
                                 control={form.control}
-                                name="actions"
-                                render={({ field }) => (
-                                    <PromotionActionsSelector
-                                        value={field.value ?? []}
-                                        onChange={field.onChange}
-                                    />
-                                )}
+                                name="couponCode"
+                                label={<Trans>Coupon code</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="perCustomerUsageLimit"
+                                label={<Trans>Per customer usage limit</Trans>}
+                                render={({ field }) => <Input type="number" {...field} />}
                             />
-                        </PageBlock>
-                    </PageLayout>
-                </form>
-            </Form>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="usageLimit"
+                                label={<Trans>Usage limit</Trans>}
+                                render={({ field }) => <Input type="number" {...field} />}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="Promotion" control={form.control} />
+                    <PageBlock column="main" title={<Trans>Conditions</Trans>}>
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="conditions"
+                            render={({ field }) => (
+                                <PromotionConditionsSelector
+                                    value={field.value ?? []}
+                                    onChange={field.onChange}
+                                />
+                            )}
+                        />
+                    </PageBlock>
+                    <PageBlock column="main" title={<Trans>Actions</Trans>}>
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="actions"
+                            render={({ field }) => (
+                                <PromotionActionsSelector
+                                    value={field.value ?? []}
+                                    onChange={field.onChange}
+                                />
+                            )}
+                        />
+                    </PageBlock>
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 9 - 8
packages/dashboard/src/routes/_authenticated/_roles/roles.tsx

@@ -4,8 +4,7 @@ import { RoleCodeLabel } from '@/components/shared/role-code-label.js';
 import { Badge } from '@/components/ui/badge.js';
 import { Button } from '@/components/ui/button.js';
 import { CUSTOMER_ROLE_CODE, SUPER_ADMIN_ROLE_CODE } from '@/constants.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@lingui/react/macro';
 import { createFileRoute, Link } from '@tanstack/react-router';
@@ -24,7 +23,7 @@ function RoleListPage() {
     return (
         <ListPage
             title="Roles"
-            listQuery={addCustomFields(roleListQuery)}
+            listQuery={roleListQuery}
             route={Route}
             defaultVisibility={{
                 description: true,
@@ -78,14 +77,16 @@ function RoleListPage() {
             }}
         >
             <PageActionBar>
-                <PermissionGuard requires={['CreateAdministrator']}>
-                    <Button asChild>
-                        <Link to="./new">
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateAdministrator']}>
+                        <Button asChild>
+                            <Link to="./new">
                             <PlusIcon className="mr-2 h-4 w-4" />
                             New Role
                         </Link>
-                    </Button>
-                </PermissionGuard>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 72 - 124
packages/dashboard/src/routes/_authenticated/_roles/roles_.$id.tsx

@@ -1,26 +1,22 @@
 import { ChannelSelector } from '@/components/shared/channel-selector.js';
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.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 { NEW_ENTITY_PATH } from '@/constants.js';
 import {
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
-    PageTitle
+    PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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';
@@ -29,24 +25,15 @@ import { createRoleDocument, roleDetailDocument, updateRoleDocument } from './ro
 
 export const Route = createFileRoute('/_authenticated/_roles/roles_/$id')({
     component: RoleDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(roleDetailDocument, { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.role) {
-            throw new Error(`Role with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
+    loader: detailPageRouteLoader({
+        queryDocument: roleDetailDocument,
+        breadcrumb(isNew, entity) {
+            return [
                 { path: '/roles', label: 'Roles' },
-                isNew ? <Trans>New role</Trans> : result.role.description,
-            ],
-        };
-    },
+                isNew ? <Trans>New role</Trans> : entity?.description,
+            ];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -56,9 +43,8 @@ export function RoleDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending } = useDetailPage({
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
         queryDocument: roleDetailDocument,
-        entityField: 'role',
         createDocument: createRoleDocument,
         updateDocument: updateRoleDocument,
         setValuesForUpdate: entity => {
@@ -72,17 +58,14 @@ export function RoleDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast(i18n.t('Successfully updated role'), {
-                position: 'top-right',
-            });
-            form.reset(form.getValues());
+            toast.success(i18n.t('Successfully updated role'));
+            resetForm();
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
-            toast(i18n.t('Failed to update role'), {
-                position: 'top-right',
+            toast.error(i18n.t('Failed to update role'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -90,13 +73,10 @@ export function RoleDetailPage() {
 
     return (
         <Page>
-            <PageTitle>
-                {creatingNewEntity ? <Trans>New role</Trans> : (entity?.description ?? '')}
-            </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageTitle>{creatingNewEntity ? <Trans>New role</Trans> : (entity?.description ?? '')}</PageTitle>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateAdministrator']}>
                             <Button
                                 type="submit"
@@ -105,93 +85,61 @@ export function RoleDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="main">
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="description"
+                                label={<Trans>Description</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="code"
+                                label={<Trans>Code</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <PageBlock column="main">
+                        <div className="space-y-8">
                             <div className="md:grid md:grid-cols-2 gap-4">
-                                <FormField
+                                <FormFieldWrapper
                                     control={form.control}
-                                    name="description"
+                                    name="channelIds"
+                                    label={<Trans>Channels</Trans>}
+                                    description={
+                                        <Trans>
+                                            The selected permissions will be applied to the these channels.
+                                        </Trans>
+                                    }
                                     render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Description</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="code"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Code</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
+                                        <ChannelSelector
+                                            multiple={true}
+                                            value={field.value ?? []}
+                                            onChange={value => field.onChange(value)}
+                                        />
                                     )}
                                 />
                             </div>
-                        </PageBlock>
-                        <PageBlock column="main">
-                            <div className="space-y-8">
-                                <div className="md:grid md:grid-cols-2 gap-4">
-                                    <FormField
-                                        control={form.control}
-                                        name="channelIds"
-                                        render={({ field }) => (
-                                            <FormItem>
-                                                <FormLabel>
-                                                    <Trans>Channels</Trans>
-                                                </FormLabel>
-                                                <FormControl>
-                                                    <ChannelSelector
-                                                        multiple={true}
-                                                        value={field.value ?? []}
-                                                        onChange={value => field.onChange(value)}
-                                                    />
-                                                </FormControl>
-                                                <FormMessage />
-                                                <FormDescription>
-                                                    <Trans>
-                                                        The selected permissions will be applied to the these
-                                                        channels.
-                                                    </Trans>
-                                                </FormDescription>
-                                            </FormItem>
-                                        )}
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="permissions"
+                                label={<Trans>Permissions</Trans>}
+                                render={({ field }) => (
+                                    <PermissionsGrid
+                                        value={field.value ?? []}
+                                        onChange={value => field.onChange(value)}
                                     />
-                                </div>
-                                <FormField
-                                    control={form.control}
-                                    name="permissions"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Permissions</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <PermissionsGrid
-                                                    value={field.value ?? []}
-                                                    onChange={value => field.onChange(value)}
-                                                />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                        </PageBlock>
-                    </PageLayout>
-                </form>
-            </Form>
+                                )}
+                            />
+                        </div>
+                    </PageBlock>
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 12 - 10
packages/dashboard/src/routes/_authenticated/_sellers/sellers.tsx

@@ -1,12 +1,13 @@
+import { DetailPageButton } from '@/components/shared/detail-page-button.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { Button } from '@/components/ui/button.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@lingui/react/macro';
 import { Link, createFileRoute } from '@tanstack/react-router';
-import { sellerListQuery } from './sellers.graphql.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
-import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { PlusIcon } from 'lucide-react';
-import { Button } from '@/components/ui/button.js';
-import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { sellerListQuery } from './sellers.graphql.js';
+
 export const Route = createFileRoute('/_authenticated/_sellers/sellers')({
     component: SellerListPage,
     loader: () => ({ breadcrumb: () => <Trans>Sellers</Trans> }),
@@ -34,15 +35,16 @@ function SellerListPage() {
             }}
         >
             <PageActionBar>
-                <div></div>
-                <PermissionGuard requires={['CreateSeller']}>
-                    <Button asChild>
-                        <Link to="./new">
+                <PageActionBarRight>    
+                    <PermissionGuard requires={['CreateSeller']}>
+                        <Button asChild>
+                            <Link to="./new">
                             <PlusIcon className="mr-2 h-4 w-4" />
                             New Seller
                         </Link>
                     </Button>
-                </PermissionGuard>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 9 - 20
packages/dashboard/src/routes/_authenticated/_sellers/sellers_.$id.tsx

@@ -27,27 +27,17 @@ import { CustomerSelector } from '@/components/shared/customer-selector.js';
 import { api } from '@/graphql/api.js';
 import { addCustomerToGroupDocument } from '../_customers/customers.graphql.js';
 import { useMutation } from '@tanstack/react-query';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
 
 export const Route = createFileRoute('/_authenticated/_sellers/sellers_/$id')({
     component: SellerDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(sellerDetailDocument), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.seller) {
-            throw new Error(`Seller with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
-                { path: '/sellers', label: 'Sellers' },
-                    isNew ? <Trans>New seller</Trans> : result.seller.name,
-            ],
-        };
-    },
+    loader: detailPageRouteLoader({
+        queryDocument: sellerDetailDocument,
+        breadcrumb: (isNew, entity) => [
+            { path: '/sellers', label: 'Sellers' },
+            isNew ? <Trans>New seller</Trans> : entity?.name,
+        ],
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -58,8 +48,7 @@ export function SellerDetailPage() {
     const { i18n } = useLingui();
 
     const { form, submitHandler, entity, isPending } = useDetailPage({
-        queryDocument: addCustomFields(sellerDetailDocument),
-        entityField: 'seller',
+        queryDocument: sellerDetailDocument,
         createDocument: createSellerDocument,
         updateDocument: updateSellerDocument,
         setValuesForUpdate: entity => {

+ 5 - 6
packages/dashboard/src/routes/_authenticated/_shipping-methods/components/fulfillment-handler-selector.tsx

@@ -1,9 +1,8 @@
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
 import { api } from '@/graphql/api.js';
-import { ConfigurableOperationDefFragment, configurableOperationDefFragment } from '@/graphql/fragments.js';
-import { useQuery } from '@tanstack/react-query';
+import { configurableOperationDefFragment } from '@/graphql/fragments.js';
 import { graphql } from '@/graphql/graphql.js';
-import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
-import { Select, SelectValue, SelectTrigger, SelectItem, SelectContent } from '@/components/ui/select.js';
+import { useQuery } from '@tanstack/react-query';
 
 export const fulfillmentHandlersDocument = graphql(
     `
@@ -17,8 +16,8 @@ export const fulfillmentHandlersDocument = graphql(
 );
 
 interface FulfillmentHandlerSelectorProps {
-    value: string | null;
-    onChange: (value: string | null) => void;
+    value: string | undefined;
+    onChange: (value: string | undefined) => void;
 }
 
 export function FulfillmentHandlerSelector({ value, onChange }: FulfillmentHandlerSelectorProps) {

+ 3 - 3
packages/dashboard/src/routes/_authenticated/_shipping-methods/components/shipping-calculator-selector.tsx

@@ -26,8 +26,8 @@ export const shippingCalculatorsDocument = graphql(
 );
 
 interface ShippingCalculatorSelectorProps {
-    value: ConfigurableOperationInputType | null;
-    onChange: (value: ConfigurableOperationInputType | null) => void;
+    value: ConfigurableOperationInputType | undefined;
+    onChange: (value: ConfigurableOperationInputType | undefined) => void;
 }
 
 export function ShippingCalculatorSelector({ value, onChange }: ShippingCalculatorSelectorProps) {
@@ -62,7 +62,7 @@ export function ShippingCalculatorSelector({ value, onChange }: ShippingCalculat
     };
 
     const onOperationRemove = () => {
-        onChange(null);
+        onChange(undefined);
     };
 
     const calculatorDef = calculators?.find(c => c.code === value?.code);

+ 3 - 3
packages/dashboard/src/routes/_authenticated/_shipping-methods/components/shipping-eligibility-checker-selector.tsx

@@ -26,8 +26,8 @@ export const shippingEligibilityCheckersDocument = graphql(
 );
 
 interface ShippingEligibilityCheckerSelectorProps {
-    value: ConfigurableOperationInputType | null;
-    onChange: (value: ConfigurableOperationInputType | null) => void;
+    value: ConfigurableOperationInputType | undefined;
+    onChange: (value: ConfigurableOperationInputType | undefined) => void;
 }
 
 export function ShippingEligibilityCheckerSelector({ value, onChange }: ShippingEligibilityCheckerSelectorProps) {
@@ -62,7 +62,7 @@ export function ShippingEligibilityCheckerSelector({ value, onChange }: Shipping
     };
 
     const onOperationRemove = () => {
-        onChange(null);
+        onChange(undefined);
     };
 
     const checkerDef = checkers?.find(c => c.code === value?.code);

+ 19 - 18
packages/dashboard/src/routes/_authenticated/_shipping-methods/shipping-methods.tsx

@@ -1,14 +1,13 @@
-import { Link, createFileRoute } from '@tanstack/react-router';
-import { Trans } from '@lingui/react/macro';
-import { ListPage } from '@/framework/page/list-page.js';
-import { shippingMethodListQuery } from './shipping-methods.graphql.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
-import { PlusIcon, TestTube } from 'lucide-react';
-import { Button } from '@/components/ui/button.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { Button } from '@/components/ui/button.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
+import { ListPage } from '@/framework/page/list-page.js';
+import { Trans } from '@lingui/react/macro';
+import { Link, createFileRoute } from '@tanstack/react-router';
+import { PlusIcon } from 'lucide-react';
 import { TestShippingMethodDialog } from './components/test-shipping-method-dialog.js';
+import { shippingMethodListQuery } from './shipping-methods.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_shipping-methods/shipping-methods')({
     component: ShippingMethodListPage,
@@ -18,7 +17,7 @@ export const Route = createFileRoute('/_authenticated/_shipping-methods/shipping
 function ShippingMethodListPage() {
     return (
         <ListPage
-            listQuery={addCustomFields(shippingMethodListQuery)}
+            listQuery={shippingMethodListQuery}
             route={Route}
             title="Shipping Methods"
             defaultVisibility={{
@@ -39,15 +38,17 @@ function ShippingMethodListPage() {
             }}
         >
             <PageActionBar>
-                <PermissionGuard requires={['CreateShippingMethod']}>
-                    <Button asChild>
-                        <Link to="./new">
-                            <PlusIcon className="mr-2 h-4 w-4" />
-                            New Shipping Method
-                        </Link>
-                    </Button>
-                </PermissionGuard>
-                <TestShippingMethodDialog />
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateShippingMethod']}>
+                        <Button asChild>
+                            <Link to="./new">
+                                <PlusIcon className="mr-2 h-4 w-4" />
+                                <Trans>New Shipping Method</Trans>
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                    <TestShippingMethodDialog />
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 77 - 124
packages/dashboard/src/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx

@@ -1,22 +1,26 @@
-import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
+import {
+    TranslatableFormFieldWrapper
+} from '@/components/shared/translatable-form-field.js';
 import { Button } from '@/components/ui/button.js';
-import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form.js';
 import { Input } from '@/components/ui/input.js';
 import { Textarea } from '@/components/ui/textarea.js';
 import { NEW_ENTITY_PATH } from '@/constants.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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';
@@ -31,24 +35,15 @@ import {
 
 export const Route = createFileRoute('/_authenticated/_shipping-methods/shipping-methods_/$id')({
     component: ShippingMethodDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(shippingMethodDetailDocument), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.shippingMethod) {
-            throw new Error(`Shipping method with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
+    loader: detailPageRouteLoader({
+        queryDocument: shippingMethodDetailDocument,
+        breadcrumb(isNew, entity) {
+            return [
                 { path: '/shipping-methods', label: 'Shipping methods' },
-                isNew ? <Trans>New shipping method</Trans> : result.shippingMethod.name,
-            ],
-        };
-    },
+                isNew ? <Trans>New shipping method</Trans> : entity?.name,
+            ];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -58,9 +53,8 @@ export function ShippingMethodDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
-        queryDocument: addCustomFields(shippingMethodDetailDocument),
-        entityField: 'shippingMethod',
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        queryDocument: shippingMethodDetailDocument,
         createDocument: createShippingMethodDocument,
         updateDocument: updateShippingMethodDocument,
         setValuesForUpdate: entity => {
@@ -89,17 +83,14 @@ export function ShippingMethodDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast(i18n.t('Successfully updated shipping method'), {
-                position: 'top-right',
-            });
-            form.reset(form.getValues());
+            toast.success(i18n.t('Successfully updated shipping method'));
+            resetForm();
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
-            toast(i18n.t('Failed to update shipping method'), {
-                position: 'top-right',
+            toast.error(i18n.t('Failed to update shipping method'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -110,10 +101,9 @@ export function ShippingMethodDetailPage() {
             <PageTitle>
                 {creatingNewEntity ? <Trans>New shipping method</Trans> : (entity?.name ?? '')}
             </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <ContentLanguageSelector />
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateShippingMethod']}>
                             <Button
                                 type="submit"
@@ -122,105 +112,68 @@ export function ShippingMethodDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 md:gap-4 mb-4">
-                                <TranslatableFormField
-                                    control={form.control}
-                                    name="name"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="code"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Code</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                            <TranslatableFormField
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <TranslatableFormFieldWrapper
                                 control={form.control}
-                                name="description"
-                                render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Description</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Textarea placeholder="" {...field} />
-                                        </FormControl>
-                                        <FormMessage />
-                                    </FormItem>
-                                )}
+                                name="name"
+                                label={<Trans>Name</Trans>}
+                                render={({ field }) => <Input {...field} />}
                             />
-                            <div className="md:grid md:grid-cols-2 md:gap-4 my-4">
-                                <FormField
-                                    control={form.control}
-                                    name="fulfillmentHandler"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Fulfillment handler</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <FulfillmentHandlerSelector
-                                                    value={field.value}
-                                                    onChange={field.onChange}
-                                                />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                        </PageBlock>
-                        <CustomFieldsPageBlock column="main" entityType="Promotion" control={form.control} />
-                        <PageBlock column="main" title={<Trans>Conditions</Trans>}>
-                            <FormField
+                            <FormFieldWrapper
                                 control={form.control}
-                                name="checker"
-                                render={({ field }) => (
-                                    <ShippingEligibilityCheckerSelector
-                                        value={field.value}
-                                        onChange={field.onChange}
-                                    />
-                                )}
+                                name="code"
+                                label={<Trans>Code</Trans>}
+                                render={({ field }) => <Input {...field} />}
                             />
-                        </PageBlock>
-                        <PageBlock column="main" title={<Trans>Calculator</Trans>}>
-                            <FormField
+                        </DetailFormGrid>
+                        <TranslatableFormFieldWrapper
+                            control={form.control}
+                            name="description"
+                            label={<Trans>Description</Trans>}
+                            render={({ field }) => <Textarea {...field} />}
+                        />
+                        <DetailFormGrid>
+                            <FormFieldWrapper
                                 control={form.control}
-                                name="calculator"
+                                name="fulfillmentHandler"
+                                label={<Trans>Fulfillment handler</Trans>}
                                 render={({ field }) => (
-                                    <ShippingCalculatorSelector
+                                    <FulfillmentHandlerSelector
                                         value={field.value}
                                         onChange={field.onChange}
                                     />
                                 )}
                             />
-                        </PageBlock>
-                    </PageLayout>
-                </form>
-            </Form>
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="Promotion" control={form.control} />
+                    <PageBlock column="main" title={<Trans>Conditions</Trans>}>
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="checker"
+                            render={({ field }) => (
+                                <ShippingEligibilityCheckerSelector
+                                    value={field.value}
+                                    onChange={field.onChange}
+                                />
+                            )}
+                        />
+                    </PageBlock>
+                    <PageBlock column="main" title={<Trans>Calculator</Trans>}>
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="calculator"
+                            render={({ field }) => (
+                                <ShippingCalculatorSelector value={field.value} onChange={field.onChange} />
+                            )}
+                        />
+                    </PageBlock>
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 17 - 16
packages/dashboard/src/routes/_authenticated/_stock-locations/stock-locations.tsx

@@ -1,13 +1,12 @@
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { ListPage } from '@/framework/page/list-page.js';
-import { Link, createFileRoute } from '@tanstack/react-router';
-import { stockLocationListQuery } from './stock-locations.graphql.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
-import { PlusIcon } from 'lucide-react';
-import { Button } from '@/components/ui/button.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { Button } from '@/components/ui/button.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
+import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@lingui/react/macro';
+import { Link, createFileRoute } from '@tanstack/react-router';
+import { PlusIcon } from 'lucide-react';
+import { stockLocationListQuery } from './stock-locations.graphql.js';
 export const Route = createFileRoute('/_authenticated/_stock-locations/stock-locations')({
     component: StockLocationListPage,
     loader: () => ({ breadcrumb: () => <Trans>Stock Locations</Trans> }),
@@ -17,7 +16,7 @@ function StockLocationListPage() {
     return (
         <ListPage
             title="Stock Locations"
-            listQuery={addCustomFields(stockLocationListQuery)}
+            listQuery={stockLocationListQuery}
             route={Route}
             customizeColumns={{
                 name: {
@@ -32,14 +31,16 @@ function StockLocationListPage() {
             }}
         >
             <PageActionBar>
-                <PermissionGuard requires={['CreateStockLocation']}>
-                    <Button asChild>
-                        <Link to="./new">
-                            <PlusIcon className="mr-2 h-4 w-4" />
-                            New Stock Location
-                        </Link>
-                    </Button>
-                </PermissionGuard>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateStockLocation']}>
+                        <Button asChild>
+                            <Link to="./new">
+                                <PlusIcon className="mr-2 h-4 w-4" />
+                                New Stock Location
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 43 - 76
packages/dashboard/src/routes/_authenticated/_stock-locations/stock-locations_.$id.tsx

@@ -1,20 +1,23 @@
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
-import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form.js';
 import { Input } from '@/components/ui/input.js';
-import { Switch } from '@/components/ui/switch.js';
+import { Textarea } from '@/components/ui/textarea.js';
 import { NEW_ENTITY_PATH } from '@/constants.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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';
@@ -23,28 +26,18 @@ import {
     stockLocationDetailQuery,
     updateStockLocationDocument,
 } from './stock-locations.graphql.js';
-import { Textarea } from '@/components/ui/textarea.js';
 
 export const Route = createFileRoute('/_authenticated/_stock-locations/stock-locations_/$id')({
     component: StockLocationDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(stockLocationDetailQuery), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.stockLocation) {
-            throw new Error(`Stock location with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
+    loader: detailPageRouteLoader({
+        queryDocument: stockLocationDetailQuery,
+        breadcrumb(isNew, entity) {
+            return [
                 { path: '/stock-locations', label: 'Stock locations' },
-                isNew ? <Trans>New stock location</Trans> : result.stockLocation.name,
-            ],
-        };
-    },
+                isNew ? <Trans>New stock location</Trans> : entity?.name,
+            ];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -54,9 +47,8 @@ export function StockLocationDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending } = useDetailPage({
-        queryDocument: addCustomFields(stockLocationDetailQuery),
-        entityField: 'stockLocation',
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        queryDocument: stockLocationDetailQuery,
         createDocument: createStockLocationDocument,
         updateDocument: updateStockLocationDocument,
         setValuesForUpdate: entity => {
@@ -69,17 +61,14 @@ export function StockLocationDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast(i18n.t('Successfully updated stock location'), {
-                position: 'top-right',
-            });
-            form.reset(form.getValues());
+            toast.success(i18n.t('Successfully updated stock location'));
+            resetForm();
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
-            toast(i18n.t('Failed to update stock location'), {
-                position: 'top-right',
+            toast.error(i18n.t('Failed to update stock location'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -90,10 +79,9 @@ export function StockLocationDetailPage() {
             <PageTitle>
                 {creatingNewEntity ? <Trans>New stock location</Trans> : (entity?.name ?? '')}
             </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateStockLocation']}>
                             <Button
                                 type="submit"
@@ -102,50 +90,29 @@ export function StockLocationDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 gap-4 mb-4">
-                                <FormField
-                                    control={form.control}
-                                    name="name"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <div></div>
-                            </div>
-                            <FormField
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <FormFieldWrapper
                                 control={form.control}
-                                name="description"
-                                render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Description</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Textarea {...field} />
-                                        </FormControl>
-                                    </FormItem>
-                                )}
+                                label={<Trans>Name</Trans>}
+                                name="name"
+                                render={({ field }) => <Input {...field} />}
                             />
-                        </PageBlock>
-                        <CustomFieldsPageBlock
-                            column="main"
-                            entityType="StockLocation"
+                            <div></div>
+                        </DetailFormGrid>
+                        <FormFieldWrapper
                             control={form.control}
+                            name="description"
+                            label={<Trans>Description</Trans>}
+                            render={({ field }) => <Textarea {...field} />}
                         />
-                    </PageLayout>
-                </form>
-            </Form>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="StockLocation" control={form.control} />
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 18 - 17
packages/dashboard/src/routes/_authenticated/_tax-categories/tax-categories.tsx

@@ -1,14 +1,13 @@
-import { Link, createFileRoute } from '@tanstack/react-router';
-import { Trans } from '@lingui/react/macro';
-import { ListPage } from '@/framework/page/list-page.js';
-import { taxCategoryListQuery } from './tax-categories.graphql.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Badge } from '@/components/ui/badge.js';
-import { PlusIcon } from 'lucide-react';
 import { Button } from '@/components/ui/button.js';
-import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
+import { ListPage } from '@/framework/page/list-page.js';
+import { Trans } from '@lingui/react/macro';
+import { Link, createFileRoute } from '@tanstack/react-router';
+import { PlusIcon } from 'lucide-react';
+import { taxCategoryListQuery } from './tax-categories.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_tax-categories/tax-categories')({
     component: TaxCategoryListPage,
@@ -18,7 +17,7 @@ export const Route = createFileRoute('/_authenticated/_tax-categories/tax-catego
 function TaxCategoryListPage() {
     return (
         <ListPage
-            listQuery={addCustomFields(taxCategoryListQuery)}
+            listQuery={taxCategoryListQuery}
             route={Route}
             title="Tax Categories"
             defaultVisibility={{
@@ -50,14 +49,16 @@ function TaxCategoryListPage() {
             }}
         >
             <PageActionBar>
-                <PermissionGuard requires={['CreateTaxCategory']}>
-                    <Button asChild>
-                        <Link to="./new">
-                            <PlusIcon />
-                            <Trans>New Tax Category</Trans>
-                        </Link>
-                    </Button>
-                </PermissionGuard>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateTaxCategory']}>
+                        <Button asChild>
+                            <Link to="./new">
+                                <PlusIcon />
+                                <Trans>New Tax Category</Trans>
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 43 - 76
packages/dashboard/src/routes/_authenticated/_tax-categories/tax-categories_.$id.tsx

@@ -1,20 +1,23 @@
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
-import { Form, FormControl, 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,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { 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';
@@ -26,27 +29,17 @@ import {
 
 export const Route = createFileRoute('/_authenticated/_tax-categories/tax-categories_/$id')({
     component: TaxCategoryDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(taxCategoryDetailQuery), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.taxCategory) {
-            throw new Error(`Tax category with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
+    loader: detailPageRouteLoader({
+        queryDocument: taxCategoryDetailQuery,
+        breadcrumb(isNew, entity) {
+            return [
                 { path: '/tax-categories', label: 'Tax categories' },
-                isNew ? <Trans>New tax category</Trans> : result.taxCategory.name,
-            ],
-        };
-    },
+                isNew ? <Trans>New tax category</Trans> : entity?.name,
+            ];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
-
 export function TaxCategoryDetailPage() {
     const params = Route.useParams();
     const navigate = useNavigate();
@@ -54,8 +47,7 @@ export function TaxCategoryDetailPage() {
     const { i18n } = useLingui();
 
     const { form, submitHandler, entity, isPending } = useDetailPage({
-        queryDocument: addCustomFields(taxCategoryDetailQuery),
-        entityField: 'taxCategory',
+        queryDocument: taxCategoryDetailQuery,
         createDocument: createTaxCategoryDocument,
         updateDocument: updateTaxCategoryDocument,
         setValuesForUpdate: entity => {
@@ -67,17 +59,14 @@ export function TaxCategoryDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast(i18n.t('Successfully updated tax category'), {
-                position: 'top-right',
-            });
+            toast.success(i18n.t('Successfully updated tax category'));
             form.reset(form.getValues());
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
-            toast(i18n.t('Failed to update tax category'), {
-                position: 'top-right',
+            toast.error(i18n.t('Failed to update tax category'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -88,10 +77,9 @@ export function TaxCategoryDetailPage() {
             <PageTitle>
                 {creatingNewEntity ? <Trans>New tax category</Trans> : (entity?.name ?? '')}
             </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateTaxCategory']}>
                             <Button
                                 type="submit"
@@ -100,49 +88,28 @@ export function TaxCategoryDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 gap-4">
-                                <FormField
-                                    control={form.control}
-                                    name="name"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="isDefault"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Is default tax category</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Switch checked={field.value} onCheckedChange={field.onChange} />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                        </PageBlock>
-                        <CustomFieldsPageBlock
-                            column="main"
-                            entityType="TaxCategory"
-                            control={form.control}
-                        />
-                    </PageLayout>
-                </form>
-            </Form>
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="name"
+                                label={<Trans>Name</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="isDefault"
+                                label={<Trans>Is default tax category</Trans>}
+                                render={({ field }) => <Switch checked={field.value} onCheckedChange={field.onChange} />}
+                            />
+                       </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="TaxCategory" control={form.control} />
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 21 - 23
packages/dashboard/src/routes/_authenticated/_tax-rates/tax-rates.tsx

@@ -1,18 +1,16 @@
-import { Link, createFileRoute } from '@tanstack/react-router';
-import { Trans } from '@lingui/react/macro';
-import { ListPage } from '@/framework/page/list-page.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { taxRateListQuery } from './tax-rates.graphql.js';
+import { BooleanDisplayBadge } from '@/components/data-display/boolean.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
-import { Badge } from '@/components/ui/badge.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { Button } from '@/components/ui/button.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
+import { ListPage } from '@/framework/page/list-page.js';
 import { api } from '@/graphql/api.js';
+import { Trans } from '@lingui/react/macro';
+import { Link, createFileRoute } from '@tanstack/react-router';
+import { PlusIcon } from 'lucide-react';
 import { taxCategoryListQuery } from '../_tax-categories/tax-categories.graphql.js';
 import { zoneListQuery } from '../_zones/zones.graphql.js';
-import { PlusIcon } from 'lucide-react';
-import { Button } from '@/components/ui/button.js';
-import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
-import { BooleanDisplayBadge } from '@/components/data-display/boolean.js';
+import { taxRateListQuery } from './tax-rates.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_tax-rates/tax-rates')({
     component: TaxRateListPage,
@@ -22,7 +20,7 @@ export const Route = createFileRoute('/_authenticated/_tax-rates/tax-rates')({
 function TaxRateListPage() {
     return (
         <ListPage
-            listQuery={addCustomFields(taxRateListQuery)}
+            listQuery={taxRateListQuery}
             route={Route}
             title="Tax Rates"
             defaultVisibility={{
@@ -77,9 +75,7 @@ function TaxRateListPage() {
                 },
                 enabled: {
                     header: 'Enabled',
-                    cell: ({ row }) => (
-                        <BooleanDisplayBadge value={row.original.enabled} />
-                    ),
+                    cell: ({ row }) => <BooleanDisplayBadge value={row.original.enabled} />,
                 },
                 category: {
                     header: 'Category',
@@ -96,14 +92,16 @@ function TaxRateListPage() {
             }}
         >
             <PageActionBar>
-                <PermissionGuard requires={['CreateTaxRate']}>
-                    <Button asChild>
-                        <Link to="./new">
-                            <PlusIcon />
-                            <Trans>New Tax Rate</Trans>
-                        </Link>
-                    </Button>
-                </PermissionGuard>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateTaxRate']}>
+                        <Button asChild>
+                            <Link to="./new">
+                                <PlusIcon />
+                                <Trans>New Tax Rate</Trans>
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 75 - 121
packages/dashboard/src/routes/_authenticated/_tax-rates/tax-rates_.$id.tsx

@@ -9,51 +9,46 @@ import { NEW_ENTITY_PATH } from '@/constants.js';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { 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 { createTaxRateDocument, taxRateDetailQuery, updateTaxRateDocument } from './tax-rates.graphql.js';
 import { ZoneSelector } from '@/components/shared/zone-selector.js';
 import { Switch } from '@/components/ui/switch.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
+
 export const Route = createFileRoute('/_authenticated/_tax-rates/tax-rates_/$id')({
     component: TaxRateDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(taxRateDetailQuery), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.taxRate) {
-            throw new Error(`Tax rate with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
+    loader: detailPageRouteLoader({
+        queryDocument: taxRateDetailQuery,
+        breadcrumb(isNew, entity) {
+            return [
                 { path: '/tax-rates', label: 'Tax rates' },
-                isNew ? <Trans>New tax rate</Trans> : result.taxRate.name,
-            ],
-        };
-    },
+                isNew ? <Trans>New tax rate</Trans> : entity?.name,
+            ];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
-
 export function TaxRateDetailPage() {
     const params = Route.useParams();
     const navigate = useNavigate();
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending } = useDetailPage({
-        queryDocument: addCustomFields(taxRateDetailQuery),
-        entityField: 'taxRate',
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        queryDocument: taxRateDetailQuery,
         createDocument: createTaxRateDocument,
         updateDocument: updateTaxRateDocument,
         setValuesForUpdate: entity => {
@@ -70,17 +65,14 @@ export function TaxRateDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast(i18n.t('Successfully updated tax rate'), {
-                position: 'top-right',
-            });
-            form.reset(form.getValues());
+            toast.success(i18n.t('Successfully updated tax rate'));
+            resetForm();
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
-            toast(i18n.t('Failed to update tax rate'), {
-                position: 'top-right',
+            toast.error(i18n.t('Failed to update tax rate'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -89,10 +81,9 @@ export function TaxRateDetailPage() {
     return (
         <Page>
             <PageTitle>{creatingNewEntity ? <Trans>New tax rate</Trans> : (entity?.name ?? '')}</PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateTaxRate']}>
                             <Button
                                 type="submit"
@@ -101,98 +92,61 @@ export function TaxRateDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="side">
-                            <FormField
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="side">
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="enabled"
+                            label={<Trans>Enabled</Trans>}
+                            render={({ field }) => (
+                                <Switch checked={field.value} onCheckedChange={field.onChange} />
+                            )}
+                        />
+                    </PageBlock>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="name"
+                                label={<Trans>Name</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="value"
+                                label={<Trans>Rate</Trans>}
+                                render={({ field }) => (
+                                    <AffixedInput
+                                        type="number"
+                                        suffix="%"
+                                        value={field.value}
+                                        onChange={e => field.onChange(e.target.valueAsNumber)}
+                                    />
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="categoryId"
+                                label={<Trans>Tax category</Trans>}
+                                render={({ field }) => (
+                                    <TaxCategorySelector value={field.value} onChange={field.onChange} />
+                                )}
+                            />
+                            <FormFieldWrapper
                                 control={form.control}
-                                name="enabled"
+                                name="zoneId"
+                                label={<Trans>Zone</Trans>}
                                 render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Enabled</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <Switch checked={field.value} onCheckedChange={field.onChange} />
-                                        </FormControl>
-                                        <FormMessage />
-                                    </FormItem>
+                                    <ZoneSelector value={field.value} onChange={field.onChange} />
                                 )}
                             />
-                        </PageBlock>
-                        <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 gap-4">
-                                <FormField
-                                    control={form.control}
-                                    name="name"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="value"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Rate</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <AffixedInput
-                                                    type="number"
-                                                    suffix="%"
-                                                    value={field.value}
-                                                    onChange={e => field.onChange(e.target.valueAsNumber)}
-                                                />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="categoryId"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Tax category</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <TaxCategorySelector
-                                                    value={field.value}
-                                                    onChange={field.onChange}
-                                                />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                                <FormField
-                                    control={form.control}
-                                    name="zoneId"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Zone</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <ZoneSelector value={field.value} onChange={field.onChange} />
-                                            </FormControl>
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
-                        </PageBlock>
-                        <CustomFieldsPageBlock column="main" entityType="TaxRate" control={form.control} />
-                    </PageLayout>
-                </form>
-            </Form>
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="TaxRate" control={form.control} />
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

+ 18 - 17
packages/dashboard/src/routes/_authenticated/_zones/zones.tsx

@@ -1,14 +1,13 @@
-import { Trans } from '@lingui/react/macro';
-import { createFileRoute, Link } from '@tanstack/react-router';
-import { ListPage } from '@/framework/page/list-page.js';
-import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { zoneListQuery } from './zones.graphql.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
-import { ZoneCountriesSheet } from './components/zone-countries-sheet.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
+import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
+import { ListPage } from '@/framework/page/list-page.js';
+import { Trans } from '@lingui/react/macro';
+import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
-import { PermissionGuard } from '@/components/shared/permission-guard.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { ZoneCountriesSheet } from './components/zone-countries-sheet.js';
+import { zoneListQuery } from './zones.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_zones/zones')({
     component: ZoneListPage,
@@ -18,7 +17,7 @@ export const Route = createFileRoute('/_authenticated/_zones/zones')({
 function ZoneListPage() {
     return (
         <ListPage
-            listQuery={addCustomFields(zoneListQuery)}
+            listQuery={zoneListQuery}
             route={Route}
             title="Zones"
             defaultVisibility={{
@@ -42,14 +41,16 @@ function ZoneListPage() {
             }}
         >
             <PageActionBar>
-                <PermissionGuard requires={['CreateZone']}>
-                    <Button asChild>
-                        <Link to="./new">
-                            <PlusIcon />
-                            <Trans>New Zone</Trans>
-                        </Link>
-                    </Button>
-                </PermissionGuard>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateZone']}>
+                        <Button asChild>
+                            <Link to="./new">
+                                <PlusIcon />
+                                <Trans>New Zone</Trans>
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
             </PageActionBar>
         </ListPage>
     );

+ 42 - 73
packages/dashboard/src/routes/_authenticated/_zones/zones_.$id.tsx

@@ -8,9 +8,12 @@ import { NEW_ENTITY_PATH } from '@/constants.js';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
+    DetailFormGrid,
     Page,
     PageActionBar,
+    PageActionBarRight,
     PageBlock,
+    PageDetailForm,
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
@@ -18,33 +21,19 @@ import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detai
 import { Trans, useLingui } from '@lingui/react/macro';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
 import { toast } from 'sonner';
-import {
-    createZoneDocument,
-    zoneDetailQuery,
-    updateZoneDocument,
-} from './zones.graphql.js';
+import { createZoneDocument, zoneDetailQuery, updateZoneDocument } from './zones.graphql.js';
 import { ZoneCountriesTable } from './components/zone-countries-table.js';
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
 
 export const Route = createFileRoute('/_authenticated/_zones/zones_/$id')({
     component: ZoneDetailPage,
-    loader: async ({ context, params }) => {
-        const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew
-            ? null
-            : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(zoneDetailQuery), { id: params.id }),
-                  { id: params.id },
-              );
-        if (!isNew && !result.zone) {
-            throw new Error(`Zone with the ID ${params.id} was not found`);
-        }
-        return {
-            breadcrumb: [
-                { path: '/zones', label: 'Zones' },
-                isNew ? <Trans>New zone</Trans> : result.zone.name,
-            ],
-        };
-    },
+    loader: detailPageRouteLoader({
+        queryDocument: zoneDetailQuery,
+        breadcrumb(isNew, entity) {
+            return [{ path: '/zones', label: 'Zones' }, isNew ? <Trans>New zone</Trans> : entity?.name];
+        },
+    }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -54,9 +43,8 @@ export function ZoneDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending } = useDetailPage({
-        queryDocument: addCustomFields(zoneDetailQuery),
-        entityField: 'zone',
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        queryDocument: zoneDetailQuery,
         createDocument: createZoneDocument,
         updateDocument: updateZoneDocument,
         setValuesForUpdate: entity => {
@@ -68,17 +56,14 @@ export function ZoneDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast(i18n.t('Successfully updated zone'), {
-                position: 'top-right',
-            });
-            form.reset(form.getValues());
+            toast.success(i18n.t('Successfully updated zone'));
+            resetForm();
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
-            toast(i18n.t('Failed to update zone'), {
-                position: 'top-right',
+            toast.error(i18n.t('Failed to update zone'), {
                 description: err instanceof Error ? err.message : 'Unknown error',
             });
         },
@@ -86,13 +71,10 @@ export function ZoneDetailPage() {
 
     return (
         <Page>
-            <PageTitle>
-                {creatingNewEntity ? <Trans>New zone</Trans> : (entity?.name ?? '')}
-            </PageTitle>
-            <Form {...form}>
-                <form onSubmit={submitHandler} className="space-y-8">
-                    <PageActionBar>
-                        <div></div>
+            <PageTitle>{creatingNewEntity ? <Trans>New zone</Trans> : (entity?.name ?? '')}</PageTitle>
+            <PageDetailForm form={form} submitHandler={submitHandler}>
+                <PageActionBar>
+                    <PageActionBarRight>
                         <PermissionGuard requires={['UpdateZone']}>
                             <Button
                                 type="submit"
@@ -101,40 +83,27 @@ export function ZoneDetailPage() {
                                 <Trans>Update</Trans>
                             </Button>
                         </PermissionGuard>
-                    </PageActionBar>
-                    <PageLayout>
-                        <PageBlock column="main">
-                            <div className="md:grid md:grid-cols-2 gap-4">
-                                <FormField
-                                    control={form.control}
-                                    name="name"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>
-                                                <Trans>Name</Trans>
-                                            </FormLabel>
-                                            <FormControl>
-                                                <Input placeholder="" {...field} />
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
-                            </div>
+                    </PageActionBarRight>
+                </PageActionBar>
+                <PageLayout>
+                    <PageBlock column="main">
+                        <DetailFormGrid>
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="name"
+                                label={<Trans>Name</Trans>}
+                                render={({ field }) => <Input {...field} />}
+                            />
+                        </DetailFormGrid>
+                    </PageBlock>
+                    <CustomFieldsPageBlock column="main" entityType="Zone" control={form.control} />
+                    {!creatingNewEntity && (
+                        <PageBlock column="main" title={<Trans>Countries</Trans>}>
+                            <ZoneCountriesTable zoneId={entity?.id} canAddCountries={true} />
                         </PageBlock>
-                        <CustomFieldsPageBlock
-                            column="main"
-                            entityType="Zone"
-                            control={form.control}
-                        />
-                        {!creatingNewEntity && (
-                            <PageBlock column="main" title={<Trans>Countries</Trans>}>
-                                <ZoneCountriesTable zoneId={entity?.id} canAddCountries={true} />
-                            </PageBlock>
-                        )}
-                    </PageLayout>
-                </form>
-            </Form>
+                    )}
+                </PageLayout>
+            </PageDetailForm>
         </Page>
     );
 }

Some files were not shown because too many files changed in this diff