Browse Source

feat(dashboard): Shipping method detail view

Michael Bromley 10 months ago
parent
commit
2b79d03aff

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

@@ -0,0 +1,57 @@
+import { api } from '@/graphql/api.js';
+import { ConfigurableOperationDefFragment, configurableOperationDefFragment } from '@/graphql/fragments.js';
+import { useQuery } from '@tanstack/react-query';
+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';
+
+export const fulfillmentHandlersDocument = graphql(
+    `
+        query GetFulfillmentHandlers {
+            fulfillmentHandlers {
+                ...ConfigurableOperationDef
+            }
+        }
+    `,
+    [configurableOperationDefFragment],
+);
+
+interface FulfillmentHandlerSelectorProps {
+    value: string | null;
+    onChange: (value: string | null) => void;
+}
+
+export function FulfillmentHandlerSelector({ value, onChange }: FulfillmentHandlerSelectorProps) {
+    const { data: fulfillmentHandlersData } = useQuery({    
+        queryKey: ['fulfillmentHandlers'],
+        queryFn: () => api.query(fulfillmentHandlersDocument),
+        staleTime: 1000 * 60 * 60 * 5,
+    });
+
+    const fulfillmentHandlers = fulfillmentHandlersData?.fulfillmentHandlers;
+
+    const onFulfillmentHandlerSelected = (code: string) => {
+        const fulfillmentHandler = fulfillmentHandlers?.find(fh => fh.code === code);
+        if (!fulfillmentHandler) {
+            return;
+        }
+        onChange(fulfillmentHandler.code);
+    };
+
+    return (
+        <div>
+            <Select onValueChange={onFulfillmentHandlerSelected} value={value ?? undefined}>
+                <SelectTrigger>
+                    <SelectValue placeholder="Select a fulfillment handler" />
+                </SelectTrigger>
+                <SelectContent>
+                    {fulfillmentHandlers?.map(fulfillmentHandler => (
+                        <SelectItem key={fulfillmentHandler.code} value={fulfillmentHandler.code}>
+                            {fulfillmentHandler.description}
+                        </SelectItem>
+                    ))}
+                </SelectContent>
+            </Select>
+        </div>
+    );
+}

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

@@ -0,0 +1,101 @@
+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 shippingCalculatorsDocument = graphql(
+    `
+        query GetShippingCalculators {
+            shippingCalculators {
+                ...ConfigurableOperationDef
+            }
+        }
+    `,
+    [configurableOperationDefFragment],
+);
+
+interface ShippingCalculatorSelectorProps {
+    value: ConfigurableOperationInputType | null;
+    onChange: (value: ConfigurableOperationInputType | null) => void;
+}
+
+export function ShippingCalculatorSelector({ value, onChange }: ShippingCalculatorSelectorProps) {
+    const { data: calculatorsData } = useQuery({
+        queryKey: ['shippingCalculators'],
+        queryFn: () => api.query(shippingCalculatorsDocument),
+        staleTime: 1000 * 60 * 60 * 5,
+    });
+
+    const calculators = calculatorsData?.shippingCalculators;
+
+    const onCalculatorSelected = (calculator: ConfigurableOperationDefFragment) => {
+        const calculatorDef = calculators?.find(c => c.code === calculator.code);
+        if (!calculatorDef) {
+            return;
+        }
+        onChange(
+            {
+                code: calculator.code,
+                arguments: calculatorDef.args.map(arg => ({
+                    name: arg.name,
+                    value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
+                })),
+            },
+        );
+    };
+
+    const onOperationValueChange = (
+        newVal: ConfigurableOperationInputType,
+    ) => {
+        onChange(newVal);
+    };
+
+    const onOperationRemove = () => {
+        onChange(null);
+    };
+
+    const calculatorDef = calculators?.find(c => c.code === value?.code);
+
+    return (
+        <div className="flex flex-col gap-2 mt-4">
+            {value && calculatorDef && (
+                <div className="flex flex-col gap-2">
+                    <ConfigurableOperationInput
+                        operationDefinition={calculatorDef}
+                        value={value}
+                        onChange={value => onOperationValueChange(value)}
+                        onRemove={() => onOperationRemove()}
+                    />
+                </div>
+            )}
+            <DropdownMenu>
+                {!value && (
+                    <DropdownMenuTrigger asChild>
+                        <Button variant="outline">
+                            <Plus />
+                            <Trans context="Add new promotion action">Select Shipping Calculator</Trans>
+                        </Button>
+                    </DropdownMenuTrigger>
+                )}
+                <DropdownMenuContent className="w-96">
+                    {calculators?.map(calculator => (
+                        <DropdownMenuItem key={calculator.code} onClick={() => onCalculatorSelected(calculator)}>
+                            {calculator.description}
+                        </DropdownMenuItem>
+                    ))}
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
+    );
+}

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

@@ -0,0 +1,101 @@
+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 shippingEligibilityCheckersDocument = graphql(
+    `
+        query GetShippingEligibilityCheckers {
+            shippingEligibilityCheckers {
+                ...ConfigurableOperationDef
+            }
+        }
+    `,
+    [configurableOperationDefFragment],
+);
+
+interface ShippingEligibilityCheckerSelectorProps {
+    value: ConfigurableOperationInputType | null;
+    onChange: (value: ConfigurableOperationInputType | null) => void;
+}
+
+export function ShippingEligibilityCheckerSelector({ value, onChange }: ShippingEligibilityCheckerSelectorProps) {
+    const { data: checkersData } = useQuery({
+        queryKey: ['shippingEligibilityCheckers'],
+        queryFn: () => api.query(shippingEligibilityCheckersDocument),
+        staleTime: 1000 * 60 * 60 * 5,
+    });
+
+    const checkers = checkersData?.shippingEligibilityCheckers;
+
+    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 && (
+                    <DropdownMenuTrigger asChild>
+                        <Button variant="outline">
+                            <Plus />
+                            <Trans context="Add new promotion action">Select Shipping Eligibility Checker</Trans>
+                        </Button>
+                    </DropdownMenuTrigger>
+                )}
+                <DropdownMenuContent className="w-96">
+                    {checkers?.map(checker => (
+                        <DropdownMenuItem key={checker.code} onClick={() => onCheckerSelected(checker)}>
+                            {checker.description}
+                        </DropdownMenuItem>
+                    ))}
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
+    );
+}

+ 47 - 0
packages/dashboard/src/routes/_authenticated/_shipping-methods/shipping-methods.graphql.ts

@@ -1,3 +1,4 @@
+import { configurableOperationFragment } from '@/graphql/fragments.js';
 import { graphql } from '@/graphql/graphql.js';
 
 export const shippingMethodItemFragment = graphql(`
@@ -25,3 +26,49 @@ export const shippingMethodListQuery = graphql(
     `,
     [shippingMethodItemFragment],
 );
+
+export const shippingMethodDetailDocument = graphql(
+    `
+        query ShippingMethodDetail($id: ID!) {
+            shippingMethod(id: $id) {
+                id
+                createdAt
+                updatedAt
+                code
+                name
+                description
+                fulfillmentHandlerCode
+                checker {
+                    ...ConfigurableOperation
+                }
+                calculator {
+                    ...ConfigurableOperation
+                }
+                translations {
+                    id
+                    languageCode
+                    name
+                    description
+                }
+                customFields
+            }
+        }
+    `,
+    [configurableOperationFragment],
+);
+
+export const createShippingMethodDocument = graphql(`
+    mutation CreateShippingMethod($input: CreateShippingMethodInput!) {
+        createShippingMethod(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updateShippingMethodDocument = graphql(`
+    mutation UpdateShippingMethod($input: UpdateShippingMethodInput!) {
+        updateShippingMethod(input: $input) {
+            id
+        }
+    }
+`);

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

@@ -0,0 +1,226 @@
+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, 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 { FulfillmentHandlerSelector } from './components/fulfillment-handler-selector.js';
+import { ShippingCalculatorSelector } from './components/shipping-calculator-selector.js';
+import { ShippingEligibilityCheckerSelector } from './components/shipping-eligibility-checker-selector.js';
+import {
+    createShippingMethodDocument,
+    shippingMethodDetailDocument,
+    updateShippingMethodDocument,
+} from './shipping-methods.graphql.js';
+
+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: [
+                { path: '/shipping-methods', label: 'Shipping methods' },
+                isNew ? <Trans>New shipping method</Trans> : result.shippingMethod.name,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+export function ShippingMethodDetailPage() {
+    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(shippingMethodDetailDocument),
+        entityField: 'shippingMethod',
+        createDocument: createShippingMethodDocument,
+        updateDocument: updateShippingMethodDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                name: entity.name,
+                code: entity.code,
+                description: entity.description,
+                fulfillmentHandler: entity.fulfillmentHandlerCode,
+                checker: entity.checker && {
+                    code: entity.checker?.code,
+                    arguments: entity.checker?.args,
+                },
+                calculator: entity.calculator && {
+                    code: entity.calculator?.code,
+                    arguments: entity.calculator?.args,
+                },
+                translations: entity.translations.map(translation => ({
+                    id: translation.id,
+                    languageCode: translation.languageCode,
+                    name: translation.name,
+                    description: translation.description,
+                })),
+                customFields: entity.customFields,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async data => {
+            toast(i18n.t('Successfully updated shipping 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 shipping method'), {
+                position: 'top-right',
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    return (
+        <Page>
+            <PageTitle>
+                {creatingNewEntity ? <Trans>New shipping method</Trans> : (entity?.name ?? '')}
+            </PageTitle>
+            <Form {...form}>
+                <form onSubmit={submitHandler} className="space-y-8">
+                    <PageActionBar>
+                        <ContentLanguageSelector />
+                        <PermissionGuard requires={['UpdateShippingMethod']}>
+                            <Button
+                                type="submit"
+                                disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
+                            >
+                                <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
+                                control={form.control}
+                                name="description"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>
+                                            <Trans>Description</Trans>
+                                        </FormLabel>
+                                        <FormControl>
+                                            <Textarea placeholder="" {...field} />
+                                        </FormControl>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                            <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
+                                control={form.control}
+                                name="checker"
+                                render={({ field }) => (
+                                    <ShippingEligibilityCheckerSelector
+                                        value={field.value}
+                                        onChange={field.onChange}
+                                    />
+                                )}
+                            />
+                        </PageBlock>
+                        <PageBlock column="main" title={<Trans>Calculator</Trans>}>
+                            <FormField
+                                control={form.control}
+                                name="calculator"
+                                render={({ field }) => (
+                                    <ShippingCalculatorSelector
+                                        value={field.value}
+                                        onChange={field.onChange}
+                                    />
+                                )}
+                            />
+                        </PageBlock>
+                    </PageLayout>
+                </form>
+            </Form>
+        </Page>
+    );
+}