Преглед на файлове

feat(dashboard): Enhance routing and error handling, add new product creation functionality

Michael Bromley преди 10 месеца
родител
ревизия
34933c1018

+ 2 - 1
packages/dashboard/src/app-providers.tsx

@@ -3,7 +3,7 @@ import { I18nProvider } from '@/providers/i18n-provider.js';
 import { ServerConfigProvider } from '@/providers/server-config.js';
 import { routeTree } from '@/routeTree.gen.js';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { createRouter } from '@tanstack/react-router';
+import { createRouter, ErrorComponent } from '@tanstack/react-router';
 import React from 'react';
 import { UserSettingsProvider } from './providers/user-settings.js';
 import { ThemeProvider } from './providers/theme-provider.js';
@@ -21,6 +21,7 @@ export const router = createRouter({
         auth: undefined!, // This will be set after we wrap the app in an AuthProvider
         queryClient,
     },
+    defaultErrorComponent: ({ error }: { error: Error }) => <div>Uh Oh!!! {error.message}</div>,
 });
 
 // Register things for typesafety

+ 7 - 1
packages/dashboard/src/components/data-type-components/asset.tsx

@@ -1,10 +1,16 @@
+import { Image } from "lucide-react";
+
 export interface AssetLike {
     preview: string;
     name?: string;
     focalPoint?: { x: number; y: number };
 }
 
-export function AssetThumbnail({ value }: { value: AssetLike }) {
+export function AssetThumbnail({ value }: { value?: AssetLike }) {
+    if (!value) {
+        // Placeholder for missing asset
+        return <div className="w-[50px] h-[50px] bg-muted rounded-sm flex items-center justify-center"><Image className="w-8 h-8 text-muted-foreground" /></div>;
+    }
     let url = value.preview + '?preset=tiny';
     if (value.focalPoint) {
         url += `&fpx=${value.focalPoint.x}&fpy=${value.focalPoint.y}`;

+ 1 - 0
packages/dashboard/src/components/layout/app-layout.tsx

@@ -26,3 +26,4 @@ export function AppLayout() {
         </SidebarProvider>
     );
 }
+

+ 2 - 1
packages/dashboard/src/components/layout/nav-user.tsx

@@ -33,6 +33,7 @@ import { useUserSettings } from '@/hooks/use-user-settings.js';
 import { useMemo } from 'react';
 import { Dialog, DialogTrigger } from '../ui/dialog.js';
 import { LanguageDialog } from './language-dialog.js';
+import { Theme } from '@/providers/theme-provider.js';
 
 export function NavUser() {
     const { isMobile } = useSidebar();
@@ -125,7 +126,7 @@ export function NavUser() {
                                         <DropdownMenuSubContent>
                                             <DropdownMenuRadioGroup
                                                 value={settings.theme}
-                                                onValueChange={setTheme}
+                                                onValueChange={(value) => setTheme(value as Theme)}
                                             >
                                                 <DropdownMenuRadioItem value="light">
                                                     <Sun />

+ 3 - 1
packages/dashboard/src/components/shared/entity-assets.tsx

@@ -144,7 +144,9 @@ export function EntityAssets({
 
     // Update internal state when props change
     useEffect(() => {
-        setAssets([...initialAssets]);
+        if (initialAssets.length) {
+            setAssets([...initialAssets]);
+        }
     }, [initialAssets]);
 
     useEffect(() => {

+ 31 - 0
packages/dashboard/src/components/shared/error-page.tsx

@@ -0,0 +1,31 @@
+import { Page, PageBlock, PageLayout, PageTitle } from '@/framework/layout-engine/page-layout.js';
+import { Trans } from '@lingui/react/macro';
+import { AlertCircle } from 'lucide-react';
+import { Alert, AlertTitle, AlertDescription } from '../ui/alert.js';
+
+export interface ErrorPageProps {
+    message: string;
+}
+
+/**
+ * @description
+ * A generic error page that displays an error message.
+ */
+export function ErrorPage({ message }: ErrorPageProps) {
+    return (
+        <Page>
+            <PageTitle>
+                <Trans>Error</Trans>
+            </PageTitle>
+            <PageLayout>
+                <PageBlock column="main">
+                    <Alert variant="destructive">
+                        <AlertCircle className="h-4 w-4" />
+                        <AlertTitle>Error</AlertTitle>
+                        <AlertDescription>{message}</AlertDescription>
+                    </Alert>
+                </PageBlock>
+            </PageLayout>
+        </Page>
+    );
+}

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

@@ -1,6 +1,6 @@
 import { Controller } from 'react-hook-form';
 import { FieldPath } from 'react-hook-form';
-import { useUserSettings } from '@/providers/user-settings.js';
+import { useUserSettings } from '@/hooks/use-user-settings.js';
 import { ControllerProps } from 'react-hook-form';
 import { FieldValues } from 'react-hook-form';
 

+ 1 - 0
packages/dashboard/src/constants.ts

@@ -0,0 +1 @@
+export const NEW_ENTITY_PATH = 'new';

+ 12 - 6
packages/dashboard/src/framework/form-engine/form-schema-tools.ts

@@ -26,30 +26,29 @@ export function createFormSchemaFromFields(fields: FieldInfo[]) {
     return z.object(schemaConfig);
 }
 
-export function getDefaultValuesFromFields(fields: FieldInfo[]) {
+export function getDefaultValuesFromFields(fields: FieldInfo[], defaultLanguageCode?: string) {
     const defaultValues: Record<string, any> = {};
     for (const field of fields) {
         if (field.typeInfo) {
-            const nestedObjectDefaults = getDefaultValuesFromFields(field.typeInfo);
+            const nestedObjectDefaults = getDefaultValuesFromFields(field.typeInfo, defaultLanguageCode);
             if (field.list) {
                 defaultValues[field.name] = [nestedObjectDefaults];
             } else {
                 defaultValues[field.name] = nestedObjectDefaults;
             }
         } else {
-            defaultValues[field.name] = getDefaultValueFromField(field);
+            defaultValues[field.name] = getDefaultValueFromField(field, defaultLanguageCode);
         }
     }
     return defaultValues;
 }
 
-export function getDefaultValueFromField(field: FieldInfo) {
+export function getDefaultValueFromField(field: FieldInfo, defaultLanguageCode?: string) {
     if (field.list) {
         return [];
     }
     switch (field.type) {
         case 'String':
-        case 'ID':
         case 'DateTime':
             return '';
         case 'Int':
@@ -58,8 +57,15 @@ export function getDefaultValueFromField(field: FieldInfo) {
             return 0;
         case 'Boolean':
             return false;
-        default:
+        case 'ID':
+            return undefined;
+        case 'LanguageCode':
+            return defaultLanguageCode || 'en';
+        case 'JSON':
+            return {};
+        default: {
             return '';
+        }
     }
 }
 

+ 4 - 2
packages/dashboard/src/framework/form-engine/use-generated-form.tsx

@@ -3,7 +3,8 @@ import {
     createFormSchemaFromFields,
     getDefaultValuesFromFields,
 } from '@/framework/form-engine/form-schema-tools.js';
-import { useServerConfig } from '@/providers/server-config.js';
+import { useChannel } from '@/hooks/use-channel.js';
+import { useServerConfig } from '@/hooks/use-server-config.js';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { zodResolver } from '@hookform/resolvers/zod';
 import { VariablesOf } from 'gql.tada';
@@ -34,10 +35,11 @@ export function useGeneratedForm<
     E extends Record<string, any> = Record<string, any>,
 >(options: GeneratedFormOptions<T, VarName, E>) {
     const { document, entity, setValues, onSubmit } = options;
+    const { activeChannel } = useChannel();
     const availableLanguages = useServerConfig()?.availableLanguages || [];
     const updateFields = getOperationVariablesFields(document);
     const schema = createFormSchemaFromFields(updateFields);
-    const defaultValues = getDefaultValuesFromFields(updateFields);
+    const defaultValues = getDefaultValuesFromFields(updateFields, activeChannel?.defaultLanguageCode);
     const processedEntity = ensureTranslationsForAllLanguages(entity, availableLanguages);
 
     const form = useForm({

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

@@ -60,7 +60,7 @@ export function Page({ children }: { children: React.ReactNode }) {
 
 export function PageTitle({ children }: { children: React.ReactNode }) {
     return (
-        <h1 className="text-2xl font-bold">{children}</h1>
+        <h1 className="text-2xl font-bold mb-4">{children}</h1>
     );
 }
 

+ 4 - 1
packages/dashboard/src/framework/page/list-page.tsx

@@ -10,7 +10,7 @@ import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { AnyRouter, useNavigate } from '@tanstack/react-router';
 import { ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
 import { ResultOf } from 'gql.tada';
-import { Page, PageTitle } from '../layout-engine/page-layout.js';
+import { Page, PageActionBar, PageTitle } from '../layout-engine/page-layout.js';
 
 type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
     [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
@@ -30,6 +30,7 @@ export interface ListPageProps<
     customizeColumns?: CustomizeColumnConfig<T>;
     defaultColumnOrder?: (keyof ListQueryFields<T>)[];
     defaultVisibility?: Partial<Record<keyof ListQueryFields<T>, boolean>>;
+    children?: React.ReactNode;
 }
 
 export function ListPage<
@@ -43,6 +44,7 @@ export function ListPage<
     route: routeOrFn,
     defaultVisibility,
     onSearchTermChange,
+    children,
 }: ListPageProps<T, U, V>) {
     const route = typeof routeOrFn === 'function' ? routeOrFn() : routeOrFn;
     const routeSearch = route.useSearch();
@@ -86,6 +88,7 @@ export function ListPage<
     return (
         <Page>
             <PageTitle>{title}</PageTitle>
+            <PageActionBar>{children}</PageActionBar>
             <PaginatedListDataTable
                 listQuery={listQuery}
                 customizeColumns={customizeColumns}

+ 46 - 15
packages/dashboard/src/framework/page/use-detail-page.ts

@@ -1,3 +1,4 @@
+import { NEW_ENTITY_PATH } from '@/constants.js';
 import { api } from '@/graphql/api.js';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { queryOptions, useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
@@ -5,14 +6,15 @@ import { ResultOf, VariablesOf } from 'gql.tada';
 import { DocumentNode } from 'graphql';
 import { Variables } from 'graphql-request';
 
-import { getQueryName } from '../document-introspection/get-document-structure.js';
+import { getMutationName, getQueryName } from '../document-introspection/get-document-structure.js';
 import { useGeneratedForm } from '../form-engine/use-generated-form.js';
 
 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>,
-    VarName extends keyof VariablesOf<U> = 'input',
+    VarNameUpdate extends keyof VariablesOf<U> = 'input',
 > {
     /**
      * @description
@@ -31,6 +33,11 @@ export interface DetailPageOptions<
     params: {
         id: string;
     };
+    /**
+     * @description
+     * The document to create the entity.
+     */
+    createDocument: C;
     /**
      * @description
      * The document to update the entity.
@@ -40,12 +47,12 @@ export interface DetailPageOptions<
      * @description
      * The function to set the values for the update document.
      */
-    setValuesForUpdate: (entity: NonNullable<ResultOf<T>[EntityField]>) => VariablesOf<U>[VarName];
+    setValuesForUpdate: (entity: NonNullable<ResultOf<T>[EntityField]>) => VariablesOf<U>[VarNameUpdate];
     /**
      * @description
      * The function to call when the update is successful.
      */
-    onSuccess?: () => void;
+    onSuccess?: (entity: ResultOf<C>[keyof ResultOf<C>] | ResultOf<U>[keyof ResultOf<U>]) => void;
     /**
      * @description
      * The function to call when the update is successful.
@@ -71,34 +78,58 @@ export function getDetailQueryOptions<T, V extends Variables = Variables>(
  */
 export function useDetailPage<
     T extends TypedDocumentNode<any, any>,
+    C extends TypedDocumentNode<any, any>,
     U extends TypedDocumentNode<any, any>,
     EntityField extends keyof ResultOf<T> = keyof ResultOf<T>,
-    VarName extends keyof VariablesOf<U> = 'input',
->(options: DetailPageOptions<T, U, EntityField, VarName>) {
-    const { queryDocument, updateDocument, setValuesForUpdate, params, entityField, onSuccess, onError } =
-        options;
+    VarNameUpdate extends keyof VariablesOf<U> = 'input',
+    VarNameCreate extends keyof VariablesOf<C> = 'input',
+>(options: DetailPageOptions<T, C, U, EntityField, VarNameUpdate>) {
+    const {
+        queryDocument,
+        createDocument,
+        updateDocument,
+        setValuesForUpdate,
+        params,
+        entityField,
+        onSuccess,
+        onError,
+    } = options;
+    const isNew = params.id === NEW_ENTITY_PATH;
     const queryClient = useQueryClient();
-    const detailQueryOptions = getDetailQueryOptions(queryDocument, { id: params.id });
+    const detailQueryOptions = getDetailQueryOptions(queryDocument, { id: isNew ? '__NEW__' : params.id });
     const detailQuery = useSuspenseQuery(detailQueryOptions);
-    const entity = detailQuery.data[entityField];
+    const entity = detailQuery?.data[entityField];
+
+    const createMutation = useMutation({
+        mutationFn: api.mutate(createDocument),
+        onSuccess: data => {
+            const createMutationName = getMutationName(createDocument);
+            onSuccess?.((data as any)[createMutationName]);
+        },
+    });
 
     const updateMutation = useMutation({
         mutationFn: api.mutate(updateDocument),
-        onSuccess: () => {
-            onSuccess?.();
+        onSuccess: data => {
+            const updateMutationName = getMutationName(updateDocument);
+            onSuccess?.((data as any)[updateMutationName]);
             void queryClient.invalidateQueries({ queryKey: detailQueryOptions.queryKey });
         },
         onError,
     });
 
     const { form, submitHandler } = useGeneratedForm({
-        document: updateDocument,
+        document: isNew ? createDocument : updateDocument,
         entity,
         setValues: setValuesForUpdate,
         onSubmit(values: any) {
-            updateMutation.mutate({ input: values });
+            if (isNew) {
+                createMutation.mutate({ input: values });
+            } else {
+                updateMutation.mutate({ input: values });
+            }
         },
     });
 
-    return { form, submitHandler, entity, isPending: updateMutation.isPending || detailQuery.isPending };
+    return { form, submitHandler, entity, isPending: updateMutation.isPending || detailQuery?.isPending };
 }

+ 11 - 0
packages/dashboard/src/routes/_authenticated/_products/products.graphql.ts

@@ -86,6 +86,17 @@ export const productDetailDocument = graphql(
     [productDetailFragment],
 );
 
+export const createProductDocument = graphql(
+    `
+        mutation CreateProduct($input: CreateProductInput!) {
+            createProduct(input: $input) {
+                ...ProductDetail
+            }
+        }
+    `,
+    [productDetailFragment],
+);
+
 export const updateProductDocument = graphql(
     `
         mutation UpdateProduct($input: UpdateProductInput!) {

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

@@ -2,6 +2,8 @@ import { Button } from '@/components/ui/button.js';
 import { ListPage } from '@/framework/page/list-page.js';
 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';
 
 export const Route = createFileRoute('/_authenticated/_products/products')({
     component: ProductListPage,
@@ -31,6 +33,16 @@ export function ProductListPage() {
             }}
             listQuery={productListDocument}
             route={Route}
-        />
+        >
+            <PageActionBar>
+                <div></div>
+                <Button asChild>
+                    <Link to="./new">
+                        <PlusIcon className="mr-2 h-4 w-4" />
+                        New Product
+                    </Link>
+                </Button>
+            </PageActionBar>
+        </ListPage>
     );
 }

+ 37 - 19
packages/dashboard/src/routes/_authenticated/_products/products_.$id.tsx

@@ -24,31 +24,45 @@ import {
 } from '@/framework/layout-engine/page-layout.js';
 import { useDetailPage, getDetailQueryOptions } from '@/framework/page/use-detail-page.js';
 import { Trans, useLingui } from '@lingui/react/macro';
-import { createFileRoute } from '@tanstack/react-router';
+import { createFileRoute, useNavigate } from '@tanstack/react-router';
 import { toast } from 'sonner';
 import { ProductVariantsTable } from './components/product-variants-table.js';
-import { productDetailDocument, updateProductDocument } from './products.graphql.js';
-
+import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
+import { NEW_ENTITY_PATH } from '@/constants.js';
+import { notFound } from '@tanstack/react-router';
+import { ErrorPage } from '@/components/shared/error-page.js';
 export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
     component: ProductDetailPage,
     loader: async ({ context, params }) => {
-        const result = await context.queryClient.ensureQueryData(
+        const isNew = params.id === NEW_ENTITY_PATH;
+        const result = isNew ? null : await context.queryClient.ensureQueryData(
             getDetailQueryOptions(productDetailDocument, { id: params.id }),
         );
-        return { breadcrumb: [{ path: '/products', label: 'Products' }, result.product.name] };
+        if (!isNew && !result.product) {
+            throw new Error(`Product with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: [
+                { path: '/products', label: 'Products' },
+                isNew ? <Trans>New product</Trans> : result.product.name,
+            ],
+        };
     },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
 export function ProductDetailPage() {
     const params = Route.useParams();
+    const navigate = useNavigate();
+    const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
     const { form, submitHandler, entity, isPending } = useDetailPage({
         queryDocument: productDetailDocument,
         entityField: 'product',
+        createDocument: createProductDocument,
         updateDocument: updateProductDocument,
         setValuesForUpdate: entity => {
-            // console.log(entity);
             return {
                 id: entity.id,
                 enabled: entity.enabled,
@@ -56,20 +70,24 @@ export function ProductDetailPage() {
                 assetIds: entity.assets.map(asset => asset.id),
                 facetValueIds: entity.facetValues.map(facetValue => facetValue.id),
                 translations: entity.translations.map(translation => ({
-                id: translation.id,
-                languageCode: translation.languageCode,
-                name: translation.name,
-                slug: translation.slug,
-                description: translation.description,
+                    id: translation.id,
+                    languageCode: translation.languageCode,
+                    name: translation.name,
+                    slug: translation.slug,
+                    description: translation.description,
                 })),
             };
         },
         params: { id: params.id },
-        onSuccess: () => {
+        onSuccess: (data) => {
             toast(i18n.t('Successfully updated product'), {
                 position: 'top-right',
             });
             form.reset();
+            if (creatingNewEntity) {
+                console.log(`navigating to:`, `${data.id}`);
+                navigate({ to: `../${data.id}`, from: Route.id });
+            }
         },
         onError: err => {
             toast(i18n.t('Failed to update product'), {
@@ -79,15 +97,13 @@ export function ProductDetailPage() {
         },
     });
 
-    console.log(`form state:`, form.formState); 
-
     return (
         <Page>
-            <PageTitle>{entity?.name ?? ''}</PageTitle>
+            <PageTitle>{creatingNewEntity ? <Trans>New product</Trans> : (entity?.name ?? '')}</PageTitle>
             <Form {...form}>
                 <form onSubmit={submitHandler} className="space-y-8">
                     <PageActionBar>
-                        <ContentLanguageSelector className="mb-4" />
+                        <ContentLanguageSelector />
                         <Button
                             type="submit"
                             disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
@@ -172,9 +188,11 @@ export function ProductDetailPage() {
                                 )}
                             />
                         </PageBlock>
-                        <PageBlock column="main">
-                            <ProductVariantsTable productId={params.id} />
-                        </PageBlock>
+                        {!creatingNewEntity && (
+                            <PageBlock column="main">
+                                <ProductVariantsTable productId={params.id} />
+                            </PageBlock>
+                        )}
                         <PageBlock column="side">
                             <FormField
                                 control={form.control}