Browse Source

feat(dashboard): Payment method detail view

Michael Bromley 10 months ago
parent
commit
e708f3eb56

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

@@ -15,7 +15,7 @@ export function createFormSchemaFromFields(fields: FieldInfo[]) {
         } else if (field.typeInfo) {
             let nestedType: ZodType = createFormSchemaFromFields(field.typeInfo);
             if (field.nullable) {
-                nestedType = nestedType.optional();
+                nestedType = nestedType.optional().nullable();
             }
             if (field.list) {
                 nestedType = z.array(nestedType);

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

@@ -0,0 +1,104 @@
+import { ConfigurableOperationInput } from '@/components/shared/configurable-operation-input.js';
+import { Button } from '@/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import { api } from '@/graphql/api.js';
+import { configurableOperationDefFragment, ConfigurableOperationDefFragment } from '@/graphql/fragments.js';
+import { graphql } from '@/graphql/graphql.js';
+import { Trans } from '@lingui/react/macro';
+import { useQuery } from '@tanstack/react-query';
+import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
+import { Plus } from 'lucide-react';
+
+export const paymentEligibilityCheckersDocument = graphql(
+    `
+        query GetPaymentEligibilityCheckers {
+            paymentMethodEligibilityCheckers {
+                ...ConfigurableOperationDef
+            }
+        }
+    `,
+    [configurableOperationDefFragment],
+);
+
+interface PaymentEligibilityCheckerSelectorProps {
+    value: ConfigurableOperationInputType | null;
+    onChange: (value: ConfigurableOperationInputType | null) => void;
+}
+
+export function PaymentEligibilityCheckerSelector({
+    value,
+    onChange,
+}: PaymentEligibilityCheckerSelectorProps) {
+    const { data: checkersData } = useQuery({
+        queryKey: ['paymentMethodEligibilityCheckers'],
+        queryFn: () => api.query(paymentEligibilityCheckersDocument),
+        staleTime: 1000 * 60 * 60 * 5,
+    });
+
+    const checkers = checkersData?.paymentMethodEligibilityCheckers;
+
+    const onCheckerSelected = (checker: ConfigurableOperationDefFragment) => {
+        const checkerDef = checkers?.find(c => c.code === checker.code);
+        if (!checkerDef) {
+            return;
+        }
+        onChange({
+            code: checker.code,
+            arguments: checkerDef.args.map(arg => ({
+                name: arg.name,
+                value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
+            })),
+        });
+    };
+
+    const onOperationValueChange = (newVal: ConfigurableOperationInputType) => {
+        onChange(newVal);
+    };
+
+    const onOperationRemove = () => {
+        onChange(null);
+    };
+
+    const checkerDef = checkers?.find(c => c.code === value?.code);
+
+    return (
+        <div className="flex flex-col gap-2 mt-4">
+            {value && checkerDef && (
+                <div className="flex flex-col gap-2">
+                    <ConfigurableOperationInput
+                        operationDefinition={checkerDef}
+                        value={value}
+                        onChange={value => onOperationValueChange(value)}
+                        onRemove={() => onOperationRemove()}
+                    />
+                </div>
+            )}
+            <DropdownMenu>
+                {!value?.code && (
+                    <DropdownMenuTrigger asChild>
+                        <Button variant="outline">
+                            <Plus />
+                            <Trans>Select Payment Eligibility Checker</Trans>
+                        </Button>
+                    </DropdownMenuTrigger>
+                )}
+                <DropdownMenuContent className="w-96">
+                    {checkers?.length ? (
+                        checkers?.map(checker => (
+                            <DropdownMenuItem key={checker.code} onClick={() => onCheckerSelected(checker)}>
+                                {checker.description}
+                            </DropdownMenuItem>
+                        ))
+                    ) : (
+                        <DropdownMenuItem>No checkers found</DropdownMenuItem>
+                    )}
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
+    );
+}

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

@@ -0,0 +1,100 @@
+import { ConfigurableOperationInput } from '@/components/shared/configurable-operation-input.js';
+import { Button } from '@/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import { api } from '@/graphql/api.js';
+import { configurableOperationDefFragment, ConfigurableOperationDefFragment } from '@/graphql/fragments.js';
+import { graphql } from '@/graphql/graphql.js';
+import { Trans } from '@lingui/react/macro';
+import { useQuery } from '@tanstack/react-query';
+import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
+import { Plus } from 'lucide-react';
+
+export const paymentHandlersDocument = graphql(
+    `
+        query GetPaymentHandlers {
+            paymentMethodHandlers {
+                ...ConfigurableOperationDef
+            }
+        }
+    `,
+    [configurableOperationDefFragment],
+);
+
+interface PaymentHandlerSelectorProps {
+    value: ConfigurableOperationInputType | null;
+    onChange: (value: ConfigurableOperationInputType | null) => void;
+}
+
+export function PaymentHandlerSelector({
+    value,
+    onChange,
+}: PaymentHandlerSelectorProps) {
+    const { data: handlersData } = useQuery({
+        queryKey: ['paymentMethodHandlers'],
+        queryFn: () => api.query(paymentHandlersDocument),
+        staleTime: 1000 * 60 * 60 * 5,
+    });
+
+    const handlers = handlersData?.paymentMethodHandlers;
+
+    const onHandlerSelected = (handler: ConfigurableOperationDefFragment) => {
+        const handlerDef = handlers?.find(h => h.code === handler.code);
+        if (!handlerDef) {
+            return;
+        }
+        onChange({
+            code: handler.code,
+            arguments: handlerDef.args.map(arg => ({
+                name: arg.name,
+                value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
+            })),
+        });
+    };
+
+    const onOperationValueChange = (newVal: ConfigurableOperationInputType) => {
+        onChange(newVal);
+    };
+
+    const onOperationRemove = () => {
+        onChange(null);
+    };
+
+    const handlerDef = handlers?.find(h => h.code === value?.code);
+
+    return (
+        <div className="flex flex-col gap-2 mt-4">
+            {value && handlerDef && (
+                <div className="flex flex-col gap-2">
+                    <ConfigurableOperationInput
+                        operationDefinition={handlerDef}
+                        value={value}
+                        onChange={value => onOperationValueChange(value)}
+                        onRemove={() => onOperationRemove()}
+                    />
+                </div>
+            )}
+            <DropdownMenu>
+                {!value?.code && (
+                    <DropdownMenuTrigger asChild>
+                        <Button variant="outline">
+                            <Plus />
+                            <Trans>Select Payment Handler</Trans>
+                        </Button>
+                    </DropdownMenuTrigger>
+                )}
+                <DropdownMenuContent className="w-96">
+                    {handlers?.map(handler => (
+                        <DropdownMenuItem key={handler.code} onClick={() => onHandlerSelected(handler)}>
+                            {handler.description}
+                        </DropdownMenuItem>
+                    ))}
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
+    );
+}

+ 47 - 0
packages/dashboard/src/routes/_authenticated/_payment-methods/payment-methods.graphql.ts

@@ -1,3 +1,4 @@
+import { configurableOperationFragment } from '@/graphql/fragments.js';
 import { graphql } from '@/graphql/graphql.js';
 
 export const paymentMethodItemFragment = graphql(`
@@ -25,3 +26,49 @@ export const paymentMethodListQuery = graphql(
     `,
     [paymentMethodItemFragment],
 );
+
+export const paymentMethodDetailDocument = graphql(
+    `
+        query PaymentMethodDetail($id: ID!) {
+            paymentMethod(id: $id) {
+                id
+                createdAt
+                updatedAt
+                name
+                code
+                description
+                enabled
+                translations {
+                    id
+                    languageCode
+                    name
+                    description
+                }
+                checker {
+                    ...ConfigurableOperation
+                }
+                handler {
+                    ...ConfigurableOperation
+                }
+                customFields
+            }
+        }
+    `,
+    [configurableOperationFragment],
+);
+
+export const createPaymentMethodDocument = graphql(`
+    mutation CreatePaymentMethod($input: CreatePaymentMethodInput!) {
+        createPaymentMethod(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updatePaymentMethodDocument = graphql(`
+    mutation UpdatePaymentMethod($input: UpdatePaymentMethodInput!) {
+        updatePaymentMethod(input: $input) {
+            id
+        }
+    }
+`);

+ 23 - 10
packages/dashboard/src/routes/_authenticated/_payment-methods/payment-methods.tsx

@@ -1,11 +1,14 @@
-import { Trans } from '@lingui/react/macro';
-import { createFileRoute } from '@tanstack/react-router';
-import { ListPage } from '@/framework/page/list-page.js';
+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';
+import { createFileRoute, Link } from '@tanstack/react-router';
+import { PlusIcon } from 'lucide-react';
 import { paymentMethodListQuery } from './payment-methods.graphql.js';
-import { DetailPageButton } from '@/components/shared/detail-page-button.js';
-import { Badge } from '@/components/ui/badge.js';
-import { BooleanDisplayBadge } from '@/components/data-display/boolean.js';
 
 export const Route = createFileRoute('/_authenticated/_payment-methods/payment-methods')({
     component: PaymentMethodListPage,
@@ -44,11 +47,21 @@ function PaymentMethodListPage() {
                 },
                 enabled: {
                     header: 'Enabled',
-                    cell: ({ row }) => (
-                        <BooleanDisplayBadge value={row.original.enabled} />
-                    ),
+                    cell: ({ row }) => <BooleanDisplayBadge value={row.original.enabled} />,
                 },
             }}
-        />
+        >
+            {' '}
+            <PageActionBar>
+                <PermissionGuard requires={['CreatePaymentMethod']}>
+                    <Button asChild>
+                        <Link to="./new">
+                            <PlusIcon className="mr-2 h-4 w-4" />
+                            New Payment Method
+                        </Link>
+                    </Button>
+                </PermissionGuard>
+            </PageActionBar>
+        </ListPage>
     );
 }

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

@@ -0,0 +1,230 @@
+import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
+import { Button } from '@/components/ui/button.js';
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form.js';
+import { Input } from '@/components/ui/input.js';
+import { 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,
+    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 { PaymentEligibilityCheckerSelector } from './components/payment-eligibility-checker-selector.js';
+import { PaymentHandlerSelector } from './components/payment-handler-selector.js';
+import {
+    createPaymentMethodDocument,
+    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,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+export function PaymentMethodDetailPage() {
+    const params = Route.useParams();
+    const navigate = useNavigate();
+    const creatingNewEntity = params.id === NEW_ENTITY_PATH;
+    const { i18n } = useLingui();
+
+    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
+        queryDocument: addCustomFields(paymentMethodDetailDocument),
+        entityField: 'paymentMethod',
+        createDocument: createPaymentMethodDocument,
+        updateDocument: updatePaymentMethodDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                enabled: entity.enabled,
+                name: entity.name,
+                code: entity.code,
+                description: entity.description,
+                    checker: entity.checker?.code ? {
+                    code: entity.checker?.code,
+                    arguments: entity.checker?.args,
+                } : null,
+                handler: entity.handler?.code ? {
+                    code: entity.handler?.code,
+                    arguments: entity.handler?.args,
+                } : null,
+                translations: entity.translations.map(translation => ({
+                    id: translation.id,
+                    languageCode: translation.languageCode,
+                    name: translation.name,
+                    description: translation.description,
+                })),
+                customFields: entity.customFields,
+            };
+        },
+        transformCreateInput: input => {
+            return {
+                ...input,
+                checker: input.checker?.code ? input.checker : undefined,
+                handler: input.handler,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async data => {
+            toast(i18n.t('Successfully updated payment method'), {
+                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 payment method'), {
+                position: 'top-right',
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    return (
+        <Page>
+            <PageTitle>
+                {creatingNewEntity ? <Trans>New payment method</Trans> : (entity?.name ?? '')}
+            </PageTitle>
+            <Form {...form}>
+                <form onSubmit={submitHandler} className="space-y-8">
+                    <PageActionBar>
+                        <ContentLanguageSelector />
+                        <PermissionGuard requires={['UpdatePaymentMethod']}>
+                            <Button
+                                type="submit"
+                                disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
+                            >
+                                <Trans>Update</Trans>
+                            </Button>
+                        </PermissionGuard>
+                    </PageActionBar>
+                    <PageLayout>
+                    <PageBlock column="side">
+                            <FormField
+                                control={form.control}
+                                name="enabled"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>
+                                            <Trans>Enabled</Trans>
+                                        </FormLabel>
+                                        <FormControl>
+                                            <Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
+                                        </FormControl>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                        </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>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="code"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Code</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" {...field} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </div>
+                            <TranslatableFormField
+                                control={form.control}
+                                name="description"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>
+                                            <Trans>Description</Trans>
+                                        </FormLabel>
+                                        <FormControl>
+                                            <Textarea placeholder="" {...field} />
+                                        </FormControl>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                        </PageBlock>
+                        <CustomFieldsPageBlock column="main" entityType="PaymentMethod" control={form.control} />
+                        <PageBlock column="main" title={<Trans>Payment eligibility checker</Trans>}>
+                            <FormField
+                                control={form.control}
+                                name="checker"
+                                render={({ field }) => (
+                                    <PaymentEligibilityCheckerSelector
+                                        value={field.value}
+                                        onChange={field.onChange}
+                                    />
+                                )}
+                            />
+                        </PageBlock>
+                        <PageBlock column="main" title={<Trans>Calculator</Trans>}>
+                            <FormField
+                                control={form.control}
+                                name="handler"
+                                render={({ field }) => (
+                                    <PaymentHandlerSelector
+                                        value={field.value}
+                                        onChange={field.onChange}
+                                    />
+                                )}
+                            />
+                        </PageBlock>
+                    </PageLayout>
+                </form>
+            </Form>
+        </Page>
+    );
+}