Browse Source

feat(dashboard): First iteration of detail page

Michael Bromley 10 months ago
parent
commit
5d6559a616

File diff suppressed because it is too large
+ 153 - 91
package-lock.json


+ 5 - 1
packages/dashboard/package.json

@@ -23,6 +23,7 @@
     "lingui.config.js"
   ],
   "dependencies": {
+    "@hookform/resolvers": "^4.1.3",
     "@lingui/core": "^5.2.0",
     "@lingui/react": "^5.2.0",
     "@radix-ui/react-avatar": "^1.1.3",
@@ -33,6 +34,7 @@
     "@radix-ui/react-select": "^2.1.6",
     "@radix-ui/react-separator": "^1.1.2",
     "@radix-ui/react-slot": "^1.1.2",
+    "@radix-ui/react-switch": "^1.1.3",
     "@radix-ui/react-tooltip": "^1.1.8",
     "@tailwindcss/vite": "^4.0.7",
     "@tanstack/react-query": "^5.66.7",
@@ -47,11 +49,13 @@
     "lucide-react": "^0.475.0",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
+    "react-hook-form": "^7.54.2",
     "tailwind-merge": "^3.0.1",
     "tailwindcss": "^4.0.6",
     "tailwindcss-animate": "^1.0.7",
     "unplugin-swc": "^1.5.1",
-    "use-debounce": "^10.0.4"
+    "use-debounce": "^10.0.4",
+    "zod": "^3.24.2"
   },
   "devDependencies": {
     "@eslint/js": "^9.19.0",

+ 165 - 0
packages/dashboard/src/components/ui/form.tsx

@@ -0,0 +1,165 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { Slot } from "@radix-ui/react-slot"
+import {
+  Controller,
+  FormProvider,
+  useFormContext,
+  useFormState,
+  type ControllerProps,
+  type FieldPath,
+  type FieldValues,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+  TFieldValues extends FieldValues = FieldValues,
+  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
+> = {
+  name: TName
+}
+
+const FormFieldContext = React.createContext<FormFieldContextValue>(
+  {} as FormFieldContextValue
+)
+
+const FormField = <
+  TFieldValues extends FieldValues = FieldValues,
+  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
+>({
+  ...props
+}: ControllerProps<TFieldValues, TName>) => {
+  return (
+    <FormFieldContext.Provider value={{ name: props.name }}>
+      <Controller {...props} />
+    </FormFieldContext.Provider>
+  )
+}
+
+const useFormField = () => {
+  const fieldContext = React.useContext(FormFieldContext)
+  const itemContext = React.useContext(FormItemContext)
+  const { getFieldState } = useFormContext()
+  const formState = useFormState({ name: fieldContext.name })
+  const fieldState = getFieldState(fieldContext.name, formState)
+
+  if (!fieldContext) {
+    throw new Error("useFormField should be used within <FormField>")
+  }
+
+  const { id } = itemContext
+
+  return {
+    id,
+    name: fieldContext.name,
+    formItemId: `${id}-form-item`,
+    formDescriptionId: `${id}-form-item-description`,
+    formMessageId: `${id}-form-item-message`,
+    ...fieldState,
+  }
+}
+
+type FormItemContextValue = {
+  id: string
+}
+
+const FormItemContext = React.createContext<FormItemContextValue>(
+  {} as FormItemContextValue
+)
+
+function FormItem({ className, ...props }: React.ComponentProps<"div">) {
+  const id = React.useId()
+
+  return (
+    <FormItemContext.Provider value={{ id }}>
+      <div
+        data-slot="form-item"
+        className={cn("grid gap-2", className)}
+        {...props}
+      />
+    </FormItemContext.Provider>
+  )
+}
+
+function FormLabel({
+  className,
+  ...props
+}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+  const { error, formItemId } = useFormField()
+
+  return (
+    <Label
+      data-slot="form-label"
+      data-error={!!error}
+      className={cn("data-[error=true]:text-destructive-foreground", className)}
+      htmlFor={formItemId}
+      {...props}
+    />
+  )
+}
+
+function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
+  const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+  return (
+    <Slot
+      data-slot="form-control"
+      id={formItemId}
+      aria-describedby={
+        !error
+          ? `${formDescriptionId}`
+          : `${formDescriptionId} ${formMessageId}`
+      }
+      aria-invalid={!!error}
+      {...props}
+    />
+  )
+}
+
+function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
+  const { formDescriptionId } = useFormField()
+
+  return (
+    <p
+      data-slot="form-description"
+      id={formDescriptionId}
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  )
+}
+
+function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
+  const { error, formMessageId } = useFormField()
+  const body = error ? String(error?.message ?? "") : props.children
+
+  if (!body) {
+    return null
+  }
+
+  return (
+    <p
+      data-slot="form-message"
+      id={formMessageId}
+      className={cn("text-destructive-foreground text-sm", className)}
+      {...props}
+    >
+      {body}
+    </p>
+  )
+}
+
+export {
+  useFormField,
+  Form,
+  FormItem,
+  FormLabel,
+  FormControl,
+  FormDescription,
+  FormMessage,
+  FormField,
+}

+ 29 - 0
packages/dashboard/src/components/ui/switch.tsx

@@ -0,0 +1,29 @@
+import * as React from "react"
+import * as SwitchPrimitive from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+  className,
+  ...props
+}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
+  return (
+    <SwitchPrimitive.Root
+      data-slot="switch"
+      className={cn(
+        "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+        className
+      )}
+      {...props}
+    >
+      <SwitchPrimitive.Thumb
+        data-slot="switch-thumb"
+        className={cn(
+          "bg-background pointer-events-none block size-4 rounded-full ring-0 shadow-lg transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
+        )}
+      />
+    </SwitchPrimitive.Root>
+  )
+}
+
+export { Switch }

+ 70 - 0
packages/dashboard/src/framework/internal/document-introspection/get-document-structure.ts

@@ -4,7 +4,9 @@ import {
     FieldNode,
     FragmentDefinitionNode,
     FragmentSpreadNode,
+    VariableDefinitionNode,
 } from 'graphql';
+import { NamedTypeNode, TypeNode } from 'graphql/language/ast.js';
 import { schemaInfo } from 'virtual:admin-api-schema';
 
 export interface FieldInfo {
@@ -14,6 +16,7 @@ export interface FieldInfo {
     list: boolean;
     isPaginatedList: boolean;
     isScalar: boolean;
+    typeInfo?: FieldInfo[];
 }
 
 /**
@@ -62,6 +65,35 @@ export function getListQueryFields(documentNode: DocumentNode): FieldInfo[] {
     return fields;
 }
 
+export function getOperationVariablesFields(documentNode: DocumentNode): FieldInfo[] {
+    const fields: FieldInfo[] = [];
+
+    const operationDefinition = documentNode.definitions.find(
+        (def): def is OperationDefinitionNode => def.kind === 'OperationDefinition',
+    );
+
+    if (operationDefinition?.variableDefinitions) {
+        operationDefinition.variableDefinitions.forEach(variable => {
+            const unwrappedType = unwrapVariableDefinitionType(variable.type);
+            const inputName = unwrappedType.name.value;
+            const inputFields = getInputTypeInfo(inputName);
+            fields.push(...inputFields);
+        });
+    }
+
+    return fields;
+}
+
+function unwrapVariableDefinitionType(type: TypeNode): NamedTypeNode {
+    if (type.kind === 'NonNullType') {
+        return unwrapVariableDefinitionType(type.type);
+    }
+    if (type.kind === 'ListType') {
+        return unwrapVariableDefinitionType(type.type);
+    }
+    return type;
+}
+
 export function getQueryName(documentNode: DocumentNode): string {
     const operationDefinition = documentNode.definitions.find(
         (def): def is OperationDefinitionNode =>
@@ -75,6 +107,19 @@ export function getQueryName(documentNode: DocumentNode): string {
     }
 }
 
+export function getMutationName(documentNode: DocumentNode): string {
+    const operationDefinition = documentNode.definitions.find(
+        (def): def is OperationDefinitionNode =>
+            def.kind === 'OperationDefinition' && def.operation === 'mutation',
+    );
+    const firstSelection = operationDefinition?.selectionSet.selections[0];
+    if (firstSelection?.kind === 'Field') {
+        return firstSelection.name.value;
+    } else {
+        throw new Error('Could not determine mutation name');
+    }
+}
+
 function getQueryInfo(name: string): FieldInfo {
     const fieldInfo = schemaInfo.types.Query[name];
     return {
@@ -87,6 +132,31 @@ function getQueryInfo(name: string): FieldInfo {
     };
 }
 
+function getInputTypeInfo(name: string): FieldInfo[] {
+    return Object.entries(schemaInfo.inputs[name]).map(([fieldName, fieldInfo]) => {
+        const type = fieldInfo[0];
+        const isScalar = isScalarType(type);
+        const isEnum = isEnumType(type);
+        return {
+            name: fieldName,
+            type,
+            nullable: fieldInfo[1],
+            list: fieldInfo[2],
+            isPaginatedList: fieldInfo[3],
+            isScalar,
+            typeInfo: !isScalar && !isEnum ? getInputTypeInfo(type) : undefined,
+        };
+    });
+}
+
+export function isScalarType(type: string): boolean {
+    return schemaInfo.scalars.includes(type);
+}
+
+export function isEnumType(type: string): boolean {
+    return schemaInfo.enums[type] != null;
+}
+
 function getPaginatedListType(name: string): string | undefined {
     const queryInfo = getQueryInfo(name);
     if (queryInfo.isPaginatedList) {

+ 92 - 0
packages/dashboard/src/framework/internal/form-engine/form-schema-tools.ts

@@ -0,0 +1,92 @@
+import {
+    FieldInfo,
+    isEnumType,
+    isScalarType,
+} from '@/framework/internal/document-introspection/get-document-structure.js';
+import { z, ZodRawShape, ZodType, ZodTypeAny } from 'zod';
+
+export function createFormSchemaFromFields(fields: FieldInfo[]) {
+    const schemaConfig: ZodRawShape = {};
+    for (const field of fields) {
+        const isScalar = isScalarType(field.type);
+        const isEnum = isEnumType(field.type);
+        if (isScalar || isEnum) {
+            schemaConfig[field.name] = getZodTypeFromField(field);
+        } else if (field.typeInfo) {
+            let nestedType: ZodType = createFormSchemaFromFields(field.typeInfo);
+            if (field.nullable) {
+                nestedType = nestedType.optional();
+            }
+            if (field.list) {
+                nestedType = z.array(nestedType);
+            }
+            schemaConfig[field.name] = nestedType;
+        }
+    }
+    return z.object(schemaConfig);
+}
+
+export function getDefaultValuesFromFields(fields: FieldInfo[]) {
+    const defaultValues: Record<string, any> = {};
+    for (const field of fields) {
+        if (field.typeInfo) {
+            const nestedObjectDefaults = getDefaultValuesFromFields(field.typeInfo);
+            if (field.list) {
+                defaultValues[field.name] = [nestedObjectDefaults];
+            } else {
+                defaultValues[field.name] = nestedObjectDefaults;
+            }
+        } else {
+            defaultValues[field.name] = getDefaultValueFromField(field);
+        }
+    }
+    return defaultValues;
+}
+
+export function getDefaultValueFromField(field: FieldInfo) {
+    if (field.list) {
+        return [];
+    }
+    switch (field.type) {
+        case 'String':
+        case 'ID':
+        case 'DateTime':
+            return '';
+        case 'Int':
+        case 'Float':
+        case 'Money':
+            return 0;
+        case 'Boolean':
+            return false;
+        default:
+            return '';
+    }
+}
+
+export function getZodTypeFromField(field: FieldInfo): ZodTypeAny {
+    let zodType: ZodType;
+    switch (field.type) {
+        case 'String':
+        case 'ID':
+        case 'DateTime':
+            zodType = z.string();
+            break;
+        case 'Int':
+        case 'Float':
+        case 'Money':
+            zodType = z.number();
+            break;
+        case 'Boolean':
+            zodType = z.boolean();
+            break;
+        default:
+            zodType = z.any();
+    }
+    if (field.list) {
+        zodType = z.array(zodType);
+    }
+    if (field.nullable) {
+        zodType = zodType.optional();
+    }
+    return zodType;
+}

+ 59 - 0
packages/dashboard/src/framework/internal/form-engine/use-generated-form.tsx

@@ -0,0 +1,59 @@
+import { getOperationVariablesFields } from '@/framework/internal/document-introspection/get-document-structure.js';
+import {
+    createFormSchemaFromFields,
+    getDefaultValuesFromFields,
+} from '@/framework/internal/form-engine/form-schema-tools.js';
+import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { VariablesOf } from 'gql.tada';
+import { FormEvent } from 'react';
+import { useForm, UseFormReturn } from 'react-hook-form';
+
+type FormField = 'FormField';
+
+type MapToFormField<T> =
+    T extends Array<infer U>
+        ? Array<MapToFormField<U>>
+        : T extends object
+          ? { [K in keyof Required<T>]: MapToFormField<NonNullable<T[K]>> }
+          : FormField;
+// Define InputFormField that takes a TypedDocumentNode and the name of the input variable
+type InputFormField<
+    T extends TypedDocumentNode<any, any>,
+    VarName extends keyof VariablesOf<T> = 'input',
+> = MapToFormField<NonNullable<VariablesOf<T>[VarName]>>;
+
+export function useGeneratedForm<
+    T extends TypedDocumentNode<any, any>,
+    VarName extends keyof VariablesOf<T> = 'input',
+    E = Record<string, any>,
+>(options: {
+    document: T;
+    entity: E | null | undefined;
+    setValues: (entity: NonNullable<E>) => VariablesOf<T>[VarName];
+    onSubmit?: (values: VariablesOf<T>[VarName]) => void;
+}): {
+    form: UseFormReturn<VariablesOf<T>[VarName]>;
+    submitHandler: (event: FormEvent) => (values: VariablesOf<T>[VarName]) => void;
+} {
+    const { document, entity, setValues, onSubmit } = options;
+    const updateFields = getOperationVariablesFields(document);
+    const schema = createFormSchemaFromFields(updateFields);
+    const defaultValues = getDefaultValuesFromFields(updateFields);
+    console.log(`defaultValues`, defaultValues);
+    const form = useForm({
+        resolver: zodResolver(schema),
+        defaultValues,
+        values: entity ? setValues(entity) : defaultValues,
+    });
+    let submitHandler = (event: FormEvent) => {
+        event.preventDefault();
+    };
+    if (onSubmit) {
+        submitHandler = (event: FormEvent) => {
+            form.handleSubmit(onSubmit)(event);
+        };
+    }
+
+    return { form, submitHandler };
+}

+ 2 - 2
packages/dashboard/src/framework/internal/page/detail-page.tsx

@@ -24,9 +24,9 @@ export interface DetailPageProps extends PageProps {
 
 export function DetailPage({ title, entity }: DetailPageProps) {
     return (
-        <div className="m-4">
+        <div>
             <h1 className="text-2xl font-bold">{title}</h1>
-            <pre>{JSON.stringify(entity, null, 2)}</pre>
+            <pre className="max-w-lg overflow-scroll">{JSON.stringify(entity, null, 2)}</pre>
         </div>
     );
 }

+ 3 - 1
packages/dashboard/src/routes/_authenticated.tsx

@@ -36,7 +36,9 @@ function AuthLayout() {
                         <GeneratedBreadcrumbs />
                     </div>
                 </header>
-                <Outlet />
+                <div className="m-4">
+                    <Outlet />
+                </div>
             </SidebarInset>
         </SidebarProvider>
     );

+ 153 - 13
packages/dashboard/src/routes/_authenticated/products_.$id.tsx

@@ -1,8 +1,24 @@
+import { Button } from '@/components/ui/button.js';
+import { Card, CardContent } from '@/components/ui/card.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 { useGeneratedForm } from '@/framework/internal/form-engine/use-generated-form.js';
 import { DetailPage, getDetailQueryOptions } from '@/framework/internal/page/detail-page.js';
+import { api } from '@/graphql/api.js';
 import { graphql } from '@/graphql/graphql.js';
-import { useSuspenseQuery } from '@tanstack/react-query';
+import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
 import { createFileRoute } from '@tanstack/react-router';
 import React from 'react';
+import { FieldValues } from 'react-hook-form';
 
 export const Route = createFileRoute('/_authenticated/products_/$id')({
     component: ProductDetailPage,
@@ -14,27 +30,151 @@ export const Route = createFileRoute('/_authenticated/products_/$id')({
     },
 });
 
-const productDetailDocument = graphql(`
-    query ProductDetail($id: ID!) {
-        product(id: $id) {
+const productDetailFragment = graphql(`
+    fragment ProductDetail on Product {
+        id
+        createdAt
+        updatedAt
+        enabled
+        name
+        slug
+        description
+        featuredAsset {
             id
-            createdAt
-            updatedAt
-            enabled
+            preview
+        }
+        assets {
+            id
+            preview
+        }
+        translations {
+            id
+            languageCode
+
             name
             slug
             description
-            featuredAsset {
-                id
-                preview
-            }
         }
     }
 `);
 
+const productDetailDocument = graphql(
+    `
+        query ProductDetail($id: ID!) {
+            product(id: $id) {
+                ...ProductDetail
+            }
+        }
+    `,
+    [productDetailFragment],
+);
+
+const updateProductDocument = graphql(
+    `
+        mutation UpdateProduct($input: UpdateProductInput!) {
+            updateProduct(input: $input) {
+                ...ProductDetail
+            }
+        }
+    `,
+    [productDetailFragment],
+);
+
 export function ProductDetailPage() {
     const params = Route.useParams();
     const detailQuery = useSuspenseQuery(getDetailQueryOptions(productDetailDocument, { id: params.id }));
-    const entity = detailQuery.data;
-    return <DetailPage title={entity.product?.name ?? ''} route={Route} entity={entity}></DetailPage>;
+    const entity = detailQuery.data.product;
+    const updateMutation = useMutation({
+        mutationFn: api.mutate(updateProductDocument),
+        onSuccess: () => {
+            console.log(`Success`);
+        },
+        onError: err => {
+            console.error(err);
+        },
+    });
+    const { form, submitHandler } = useGeneratedForm({
+        document: updateProductDocument,
+        entity,
+        setValues: entity => ({
+            id: entity.id,
+            enabled: entity.enabled,
+            featuredAssetId: entity.featuredAsset?.id,
+            assetIds: entity.assets.map(asset => asset.id),
+            translations: entity.translations.map(translation => ({
+                id: translation.id,
+                languageCode: translation.languageCode,
+                name: translation.name,
+                slug: translation.slug,
+                description: translation.description,
+            })),
+        }),
+        onSubmit(values) {
+            updateMutation.mutate({ input: values });
+        },
+    });
+
+    return (
+        <>
+            <DetailPage title={entity?.name ?? ''} route={Route} entity={entity}></DetailPage>
+            {entity && (
+                <Form {...form}>
+                    <form onSubmit={submitHandler} className="space-y-8">
+                        <Card className="">
+                            <CardContent>
+                                <FormField
+                                    control={form.control}
+                                    name="enabled"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>Enabled</FormLabel>
+                                            <FormControl>
+                                                <Switch
+                                                    checked={field.value}
+                                                    onCheckedChange={field.onChange}
+                                                />
+                                            </FormControl>
+                                            <FormDescription>
+                                                This is your public display name.
+                                            </FormDescription>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="featuredAssetId"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>featuredAssetId</FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" {...field} />
+                                            </FormControl>
+                                            <FormDescription></FormDescription>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name={`translations.${0}.name`}
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>name</FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" {...field} />
+                                            </FormControl>
+                                            <FormDescription></FormDescription>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </CardContent>
+                        </Card>
+                        <Button type="submit">Submit</Button>
+                    </form>
+                </Form>
+            )}
+        </>
+    );
 }

+ 17 - 13
packages/dashboard/vite/vite-plugin-admin-api-schema.ts

@@ -14,6 +14,7 @@ import {
     GraphQLObjectType,
     GraphQLSchema,
     GraphQLType,
+    isEnumType,
     isInputObjectType,
     isObjectType,
     isScalarType,
@@ -22,28 +23,28 @@ import { Plugin } from 'vite';
 
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 
+export type FieldInfoTuple = readonly [
+    type: string,
+    nullable: boolean,
+    list: boolean,
+    isPaginatedList: boolean,
+];
+
 export interface SchemaInfo {
     types: {
         [typename: string]: {
-            [fieldname: string]: readonly [
-                type: string,
-                nullable: boolean,
-                list: boolean,
-                isPaginatedList: boolean,
-            ];
+            [fieldname: string]: FieldInfoTuple;
         };
     };
     inputs: {
         [typename: string]: {
-            [fieldname: string]: readonly [
-                type: string,
-                nullable: boolean,
-                list: boolean,
-                isPaginatedList: boolean,
-            ];
+            [fieldname: string]: FieldInfoTuple;
         };
     };
     scalars: string[];
+    enums: {
+        [typename: string]: string[];
+    };
 }
 
 const virtualModuleId = 'virtual:admin-api-schema';
@@ -121,7 +122,7 @@ function getTypeInfo(type: GraphQLType) {
 
 function generateSchemaInfo(schema: GraphQLSchema): SchemaInfo {
     const types = schema.getTypeMap();
-    const result: SchemaInfo = { types: {}, inputs: {}, scalars: [] };
+    const result: SchemaInfo = { types: {}, inputs: {}, scalars: [], enums: {} };
 
     Object.values(types).forEach(type => {
         if (isObjectType(type)) {
@@ -143,6 +144,9 @@ function generateSchemaInfo(schema: GraphQLSchema): SchemaInfo {
         if (isScalarType(type)) {
             result.scalars.push(type.name);
         }
+        if (isEnumType(type)) {
+            result.enums[type.name] = type.getValues().map(v => v.value);
+        }
     });
 
     return result;

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