Parcourir la source

feat(dashboard): Promotion detail view

Michael Bromley il y a 10 mois
Parent
commit
5d3a153881

+ 5 - 5
packages/dashboard/src/components/shared/configurable-operation-input.tsx

@@ -98,7 +98,7 @@ export function ConfigurableOperationInput({
  */
 export function interpolateDescription(
     operation: any,
-    values: { [name: string]: any },
+    values: Array<{ name: string; value: string }>,
     precisionFactor = 2,
 ): string {
     if (!operation) {
@@ -109,7 +109,7 @@ export function interpolateDescription(
         /{\s*([a-zA-Z0-9]+)\s*}/gi,
         (substring: string, argName: string) => {
             const normalizedArgName = argName.toLowerCase();
-            const value = values[normalizedArgName];
+            const value = values.find(v => v.name === normalizedArgName)?.value;
             if (value == null) {
                 return '_';
             }
@@ -121,10 +121,10 @@ export function interpolateDescription(
                 argDef.ui &&
                 argDef.ui.component === 'currency-form-input'
             ) {
-                formatted = value / Math.pow(10, precisionFactor);
+                formatted = (Number(value) / Math.pow(10, precisionFactor)).toString();
             }
-            if (argDef && argDef.type === 'datetime' && value instanceof Date) {
-                formatted = value.toLocaleDateString();
+            if (argDef && argDef.type === 'datetime' && (value as any) instanceof Date) {
+                formatted = (value as any).toLocaleDateString();
             }
             return formatted;
         },

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

@@ -36,7 +36,7 @@ import {
     updateCollectionDocument
 } from './collections.graphql.js';
 import { CollectionContentsTable } from './components/collection-contents-table.js';
-import { CollectionFiltersSelect } from './components/collection-filters-select.js';
+import { CollectionFiltersSelector } from './components/collection-filters-selector.js';
 import { CollectionContentsPreviewTable } from './components/collection-contents-preview-table.js';
 
 export const Route = createFileRoute('/_authenticated/_collections/collections_/$id')({
@@ -243,7 +243,7 @@ export function CollectionDetailPage() {
                                 control={form.control}
                                 name="filters"
                                 render={({ field }) => (
-                                    <CollectionFiltersSelect value={field.value} onChange={field.onChange} />
+                                    <CollectionFiltersSelector value={field.value ?? []} onChange={field.onChange} />
                                 )}
                             />
                         </PageBlock>

+ 2 - 2
packages/dashboard/src/routes/_authenticated/_collections/components/collection-filters-select.tsx → packages/dashboard/src/routes/_authenticated/_collections/components/collection-filters-selector.tsx

@@ -14,12 +14,12 @@ import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@v
 import { Plus } from 'lucide-react';
 import { getCollectionFiltersQueryOptions } from '../collections.graphql.js';
 
-export interface CollectionFiltersSelectProps {
+export interface CollectionFiltersSelectorProps {
     value: ConfigurableOperationInputType[];
     onChange: (filters: ConfigurableOperationInputType[]) => void;
 }
 
-export function CollectionFiltersSelect({ value, onChange }: CollectionFiltersSelectProps) {
+export function CollectionFiltersSelector({ value, onChange }: CollectionFiltersSelectorProps) {
     const { data: filtersData } = useQuery(getCollectionFiltersQueryOptions);
 
     const filters = filtersData?.collectionFilters;

+ 107 - 0
packages/dashboard/src/routes/_authenticated/_promotions/components/promotion-actions-selector.tsx

@@ -0,0 +1,107 @@
+import { graphql } from '@/graphql/graphql.js';
+import { configurableOperationDefFragment, ConfigurableOperationDefFragment } from '@/graphql/fragments.js';
+import { useQuery } from '@tanstack/react-query';
+import { Button } from '@/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import { Separator } from '@/components/ui/separator.js';
+import { Plus } from 'lucide-react';
+import { Trans } from '@lingui/react/macro';
+import { ConfigurableOperationInput } from '@/components/shared/configurable-operation-input.js';
+import { api } from '@/graphql/api.js';
+import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
+
+export const promotionActionsDocument = graphql(
+    `
+        query GetPromotionActions {
+            promotionActions {
+                ...ConfigurableOperationDef
+            }
+        }
+    `,
+    [configurableOperationDefFragment],
+);
+
+interface PromotionActionsSelectorProps {
+    value: ConfigurableOperationInputType[];
+    onChange: (value: ConfigurableOperationInputType[]) => void;
+}
+
+export function PromotionActionsSelector({ value, onChange }: PromotionActionsSelectorProps) {
+    const { data: actionsData } = useQuery({
+        queryKey: ['promotionActions'],
+        queryFn: () => api.query(promotionActionsDocument),
+        staleTime: 1000 * 60 * 60 * 5,
+    });
+
+    const actions = actionsData?.promotionActions;
+
+    const onActionSelected = (action: ConfigurableOperationDefFragment) => {
+        const actionDef = actions?.find(a => a.code === action.code);
+        if (!actionDef) {
+            return;
+        }
+        onChange([
+            ...value,
+            {
+                code: action.code,
+                arguments: actionDef.args.map(arg => ({
+                    name: arg.name,
+                    value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
+                })),
+            },
+        ]);
+    };
+
+    const onOperationValueChange = (
+        action: ConfigurableOperationInputType,
+        newVal: ConfigurableOperationInputType,
+    ) => {
+        onChange(value.map(a => (a.code === action.code ? newVal : a)));
+    };
+
+    const onOperationRemove = (index: number) => {
+        onChange(value.filter((_, i) => i !== index));
+    };
+
+    return (
+        <div className="flex flex-col gap-2 mt-4">
+            {(value ?? []).map((action, index) => {
+                const actionDef = actions?.find(a => a.code === action.code);
+                if (!actionDef) {
+                    return null;
+                }
+                return (
+                    <div key={index} className="flex flex-col gap-2">
+                        <ConfigurableOperationInput
+                            operationDefinition={actionDef}
+                            value={action}
+                            onChange={value => onOperationValueChange(action, value)}
+                            onRemove={() => onOperationRemove(index)}
+                        />
+                        <Separator className="my-2" />
+                    </div>
+                );
+            })}
+            <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                    <Button variant="outline">
+                        <Plus />
+                        <Trans context="Add new promotion action">Add action</Trans>
+                    </Button>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent className="w-96">
+                    {actions?.map(action => (
+                        <DropdownMenuItem key={action.code} onClick={() => onActionSelected(action)}>
+                            {action.description}
+                        </DropdownMenuItem>
+                    ))}
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
+    );
+}

+ 107 - 0
packages/dashboard/src/routes/_authenticated/_promotions/components/promotion-conditions-selector.tsx

@@ -0,0 +1,107 @@
+import { graphql } from '@/graphql/graphql.js';
+import { configurableOperationDefFragment, ConfigurableOperationDefFragment } from '@/graphql/fragments.js';
+import { useQuery } from '@tanstack/react-query';
+import { Button } from '@/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import { Separator } from '@/components/ui/separator.js';
+import { Plus } from 'lucide-react';
+import { Trans } from '@lingui/react/macro';
+import { ConfigurableOperationInput } from '@/components/shared/configurable-operation-input.js';
+import { api } from '@/graphql/api.js';
+import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
+
+export const promotionConditionsDocument = graphql(
+    `
+        query GetPromotionConditions {
+            promotionConditions {
+                ...ConfigurableOperationDef
+            }
+        }
+    `,
+    [configurableOperationDefFragment],
+);
+
+interface PromotionConditionsSelectorProps {
+    value: ConfigurableOperationInputType[];
+    onChange: (value: ConfigurableOperationInputType[]) => void;
+}
+
+export function PromotionConditionsSelector({ value, onChange }: PromotionConditionsSelectorProps) {
+    const { data: conditionsData } = useQuery({
+        queryKey: ['promotionConditions'],
+        queryFn: () => api.query(promotionConditionsDocument),
+        staleTime: 1000 * 60 * 60 * 5,
+    });
+
+    const conditions = conditionsData?.promotionConditions;
+
+    const onConditionSelected = (condition: ConfigurableOperationDefFragment) => {
+        const conditionDef = conditions?.find(c => c.code === condition.code);
+        if (!conditionDef) {
+            return;
+        }
+        onChange([
+            ...value,
+            {
+                code: condition.code,
+                arguments: conditionDef.args.map(arg => ({
+                    name: arg.name,
+                    value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
+                })),
+            },
+        ]);
+    };
+
+    const onOperationValueChange = (
+        condition: ConfigurableOperationInputType,
+        newVal: ConfigurableOperationInputType,
+    ) => {
+        onChange(value.map(c => (c.code === condition.code ? newVal : c)));
+    };
+
+    const onOperationRemove = (index: number) => {
+        onChange(value.filter((_, i) => i !== index));
+    };
+
+    return (
+        <div className="flex flex-col gap-2 mt-4">
+            {(value ?? []).map((condition, index) => {
+                const conditionDef = conditions?.find(c => c.code === condition.code);
+                if (!conditionDef) {
+                    return null;
+                }
+                return (
+                    <div key={index} className="flex flex-col gap-2">
+                        <ConfigurableOperationInput
+                            operationDefinition={conditionDef}
+                            value={condition}
+                            onChange={value => onOperationValueChange(condition, value)}
+                            onRemove={() => onOperationRemove(index)}
+                        />
+                        <Separator className="my-2" />
+                    </div>
+                );
+            })}
+            <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                    <Button variant="outline">
+                        <Plus />
+                        <Trans context="Add new promotion condition">Add condition</Trans>
+                    </Button>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent className="w-96">
+                    {conditions?.map(condition => (
+                        <DropdownMenuItem key={condition.code} onClick={() => onConditionSelected(condition)}>
+                            {condition.description}
+                        </DropdownMenuItem>
+                    ))}
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
+    );
+}

+ 63 - 0
packages/dashboard/src/routes/_authenticated/_promotions/promotions.graphql.ts

@@ -1,3 +1,4 @@
+import { configurableOperationFragment } from '@/graphql/fragments.js';
 import { graphql } from '@/graphql/graphql.js';
 
 export const promotionListDocument = graphql(`
@@ -20,3 +21,65 @@ export const promotionListDocument = graphql(`
         }
     }
 `);
+
+export const promotionDetailDocument = graphql(
+    `
+        query PromotionDetail($id: ID!) {
+            promotion(id: $id) {
+                id
+                createdAt
+                updatedAt
+                name
+                description
+                enabled
+                couponCode
+                perCustomerUsageLimit
+                usageLimit
+                startsAt
+                endsAt
+                conditions {
+                    ...ConfigurableOperation
+                }
+                actions {
+                    ...ConfigurableOperation
+                }
+                translations {
+                    id
+                    languageCode
+                    name
+                    description
+                }
+                customFields
+            }
+        }
+    `,
+    [configurableOperationFragment],
+);
+
+export const createPromotionDocument = graphql(`
+    mutation CreatePromotion($input: CreatePromotionInput!) {
+        createPromotion(input: $input) {
+            ... on Promotion {
+                id
+            }
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+`);
+
+export const updatePromotionDocument = graphql(`
+    mutation UpdatePromotion($input: UpdatePromotionInput!) {
+        updatePromotion(input: $input) {
+            ... on Promotion {
+                id
+            }
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+`);

+ 318 - 0
packages/dashboard/src/routes/_authenticated/_promotions/promotions_.$id.tsx

@@ -0,0 +1,318 @@
+import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
+import { EntityAssets } from '@/components/shared/entity-assets.js';
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { 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 { Switch } from '@/components/ui/switch.js';
+import { Textarea } from '@/components/ui/textarea.js';
+import { NEW_ENTITY_PATH } from '@/constants.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import {
+    CustomFieldsPageBlock,
+    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 {
+    collectionDetailDocument,
+    createCollectionDocument,
+    updateCollectionDocument,
+} from './collections.graphql.js';
+import { CollectionContentsTable } from './components/collection-contents-table.js';
+import { CollectionFiltersSelect } from './components/collection-filters-select.js';
+import { CollectionContentsPreviewTable } from './components/collection-contents-preview-table.js';
+import {
+    promotionDetailDocument,
+    createPromotionDocument,
+    updatePromotionDocument,
+} from './promotions.graphql.js';
+import { PromotionConditionsSelector } from './components/promotion-conditions-selector.js';
+import { PromotionActionsSelector } from './components/promotion-actions-selector.js';
+import { DateTimeInput } from '@/components/data-input/datetime-input.js';
+
+export const Route = createFileRoute('/_authenticated/_promotions/promotions_/$id')({
+    component: PromotionDetailPage,
+    loader: async ({ context, params }) => {
+        const isNew = params.id === NEW_ENTITY_PATH;
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(addCustomFields(promotionDetailDocument), { id: params.id }),
+                  { id: params.id },
+              );
+        if (!isNew && !result.promotion) {
+            throw new Error(`Promotion with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: [
+                { path: '/promotions', label: 'Promotions' },
+                isNew ? <Trans>New promotion</Trans> : result.promotion.name,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+export function PromotionDetailPage() {
+    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(promotionDetailDocument),
+        entityField: 'promotion',
+        createDocument: createPromotionDocument,
+        transformCreateInput: values => {
+            return {
+                ...values,
+                conditions: values.conditions.filter(c => c.code !== ''),
+                actions: values.actions.filter(a => a.code !== ''),
+            };
+        },
+        updateDocument: updatePromotionDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                name: entity.name,
+                description: entity.description,
+                enabled: entity.enabled,
+                couponCode: entity.couponCode,
+                perCustomerUsageLimit: entity.perCustomerUsageLimit,
+                usageLimit: entity.usageLimit,
+                startsAt: entity.startsAt,
+                endsAt: entity.endsAt,
+                conditions: entity.conditions.map(x => ({
+                    code: x.code,
+                    arguments: x.args.map(a => ({ name: a.name, value: a.value })),
+                })),
+                actions: entity.actions.map(x => ({
+                    code: x.code,
+                    arguments: x.args.map(a => ({ name: a.name, value: a.value })),
+                })),
+                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 promotion'), {
+                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 promotion'), {
+                position: 'top-right',
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    return (
+        <Page>
+            <PageTitle>{creatingNewEntity ? <Trans>New promotion</Trans> : (entity?.name ?? '')}</PageTitle>
+            <Form {...form}>
+                <form onSubmit={submitHandler} className="space-y-8">
+                    <PageActionBar>
+                        <ContentLanguageSelector />
+                        <PermissionGuard requires={['UpdatePromotion']}>
+                            <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} onCheckedChange={field.onChange} />
+                                        </FormControl>
+                                        <FormDescription>
+                                            <Trans>
+                                                If a promotion is enabled, it will be applied to orders in the
+                                                shop
+                                            </Trans>
+                                        </FormDescription>
+                                        <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>
+                                    )}
+                                />
+                            </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="startsAt"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Starts at</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <DateTimeInput value={field.value} onChange={value => field.onChange(value.toISOString())} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}  
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="endsAt"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Ends at</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <DateTimeInput value={field.value} onChange={value => field.onChange(value.toISOString())} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="couponCode"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Coupon code</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" {...field} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="perCustomerUsageLimit"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Per customer usage limit</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" type="number" value={field.value} onChange={e => field.onChange(e.target.valueAsNumber)} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="usageLimit"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Usage limit</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" type="number" value={field.value} onChange={e => field.onChange(e.target.valueAsNumber)} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </div>
+                        </PageBlock>
+                        <CustomFieldsPageBlock column="main" entityType="Promotion" control={form.control} />
+                        <PageBlock column="main" title={<Trans>Conditions</Trans>}>
+                            <FormField
+                                control={form.control}
+                                name="conditions"
+                                render={({ field }) => (
+                                    <PromotionConditionsSelector
+                                        value={field.value ?? []}
+                                        onChange={field.onChange}
+                                    />
+                                )}
+                            />
+                        </PageBlock>
+                        <PageBlock column="main" title={<Trans>Actions</Trans>}>
+                            <FormField
+                                control={form.control}
+                                name="actions"
+                                render={({ field }) => (
+                                    <PromotionActionsSelector
+                                        value={field.value ?? []}
+                                        onChange={field.onChange}
+                                    />
+                                )}
+                            />
+                        </PageBlock>
+                    </PageLayout>
+                </form>
+            </Form>
+        </Page>
+    );
+}