Browse Source

feat(dashboard): Zone detail view

Michael Bromley 10 months ago
parent
commit
12ecd7388d

+ 105 - 0
packages/dashboard/src/components/shared/country-selector.tsx

@@ -0,0 +1,105 @@
+import { Button } from '@/components/ui/button.js';
+import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { Trans } from '@lingui/react/macro';
+import { useQuery } from '@tanstack/react-query';
+import { Plus, Search } from 'lucide-react';
+import { useState } from 'react';
+
+const countryListDocument = graphql(`
+    query CountryList($options: CountryListOptions) {
+        countries(options: $options) {
+            items {
+                id
+                name
+                code
+            }
+            totalItems
+        }
+    }
+`);
+
+export interface Country {
+    id: string;
+    name: string;
+    code: string;
+}
+
+export interface CountrySelectorProps {
+    onSelect: (value: Country) => void;
+    label?: string | React.ReactNode;
+    readOnly?: boolean;
+}
+
+export function CountrySelector(props: CountrySelectorProps) {
+    const [open, setOpen] = useState(false);
+    const [searchTerm, setSearchTerm] = useState('');
+
+    const { data, isLoading } = useQuery({
+        queryKey: ['countries', searchTerm],
+        queryFn: () =>
+            api.query(countryListDocument, {
+                options: {
+                    sort: { name: 'ASC' },
+                    filter: searchTerm ? {
+                        name: { contains: searchTerm },
+                        code: { contains: searchTerm },
+                    } : undefined,
+                    filterOperator: searchTerm ? 'OR' : undefined,
+                },
+            }),
+        staleTime: 1000 * 60 * 60, // 1 hour
+    });
+
+    const handleSearch = (value: string) => {
+        setSearchTerm(value);
+    };
+
+    return (
+        <Popover open={open} onOpenChange={setOpen}>
+            <PopoverTrigger asChild>
+                <Button variant="outline" size="sm" type="button" disabled={props.readOnly} className="gap-2">
+                    <Plus className="h-4 w-4" />
+                    {props.label ?? <Trans>Select country</Trans>}
+                </Button>
+            </PopoverTrigger>
+            <PopoverContent className="p-0 w-[350px]" align="start">
+                <Command shouldFilter={false}>
+                    <div className="flex items-center border-b px-3">
+                        <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
+                        <CommandInput 
+                            placeholder="Search countries..." 
+                            onValueChange={handleSearch}
+                            className="h-10 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
+                        />
+                    </div>
+                    <CommandList>
+                        <CommandEmpty>
+                            {isLoading ? (
+                                <Trans>Loading...</Trans>
+                            ) : (
+                                <Trans>No countries found</Trans>
+                            )}
+                        </CommandEmpty>
+                        {data?.countries.items.map(country => (
+                            <CommandItem
+                                key={country.id}
+                                onSelect={() => {
+                                    props.onSelect(country);
+                                    setOpen(false);
+                                }}
+                                className="flex flex-col items-start"
+                            >
+                                <div className="font-medium">{country.name}</div>
+                                <div className="text-sm text-muted-foreground">{country.code}</div>
+                            </CommandItem>
+                        ))}
+                    </CommandList>
+                </Command>
+            </PopoverContent>
+        </Popover>
+    );
+}
+

+ 3 - 43
packages/dashboard/src/routes/_authenticated/_zones/components/zone-countries-sheet.tsx

@@ -1,12 +1,7 @@
 import { Button } from '@/components/ui/button.js';
 import { Button } from '@/components/ui/button.js';
-import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet.js';
-import { zoneMembersQuery } from '../zones.graphql.js';
-import { api } from '@/graphql/api.js';
-import { useQuery } from '@tanstack/react-query';
-import { DataTable } from '@/components/data-table/data-table.js';
-import { ColumnDef } from '@tanstack/react-table';
-import { useMemo, useState } from 'react';
 import { ScrollArea } from '@/components/ui/scroll-area.js';
 import { ScrollArea } from '@/components/ui/scroll-area.js';
+import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet.js';
+import { ZoneCountriesTable } from './zone-countries-table.js';
 
 
 interface ZoneCountriesSheetProps {
 interface ZoneCountriesSheetProps {
     zoneId: string;
     zoneId: string;
@@ -15,33 +10,6 @@ interface ZoneCountriesSheetProps {
 }
 }
 
 
 export function ZoneCountriesSheet({ zoneId, zoneName, children }: ZoneCountriesSheetProps) {
 export function ZoneCountriesSheet({ zoneId, zoneName, children }: ZoneCountriesSheetProps) {
-    const { data } = useQuery({
-        queryKey: ['zone', zoneId],
-        queryFn: () => api.query(zoneMembersQuery, { zoneId }),
-    });
-
-    const [page, setPage] = useState(1);
-    const [pageSize, setPageSize] = useState(10);
-
-    const paginatedItems = useMemo(() => {
-        return data?.zone?.members?.slice((page - 1) * pageSize, page * pageSize);
-    }, [data, page, pageSize]);
-
-    const columns: ColumnDef<any>[] = [
-        {
-            header: 'Country',
-            accessorKey: 'name',
-        },
-        {
-            header: 'Enabled',
-            accessorKey: 'enabled',
-        },
-        {
-            header: 'Code',
-            accessorKey: 'code',
-        },
-    ];
-
     return (
     return (
         <Sheet>
         <Sheet>
             <SheetTrigger asChild>
             <SheetTrigger asChild>
@@ -55,15 +23,7 @@ export function ZoneCountriesSheet({ zoneId, zoneName, children }: ZoneCountries
                 </SheetHeader>
                 </SheetHeader>
                 <div className="flex items-center gap-2"></div>
                 <div className="flex items-center gap-2"></div>
                 <ScrollArea className="px-6 max-h-[600px]">
                 <ScrollArea className="px-6 max-h-[600px]">
-                    <DataTable
-                        columns={columns}
-                        data={paginatedItems ?? []}
-                        onPageChange={(table, page, itemsPerPage) => {
-                            setPage(page);
-                            setPageSize(itemsPerPage);
-                        }}
-                        totalItems={data?.zone?.members?.length ?? 0}
-                    />
+                    <ZoneCountriesTable zoneId={zoneId} />
                 </ScrollArea>
                 </ScrollArea>
             </SheetContent>
             </SheetContent>
         </Sheet>
         </Sheet>

+ 79 - 0
packages/dashboard/src/routes/_authenticated/_zones/components/zone-countries-table.tsx

@@ -0,0 +1,79 @@
+import { DataTable } from '@/components/data-table/data-table.js';
+import { api } from '@/graphql/api.js';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { ColumnDef } from '@tanstack/react-table';
+import { useMemo, useState } from 'react';
+import { addCountryToZoneMutation, removeCountryFromZoneMutation, zoneMembersQuery } from '../zones.graphql.js';
+import { CountrySelector } from '@/components/shared/country-selector.js';
+
+interface ZoneCountriesTableProps {
+    zoneId: string;
+    canAddCountries?: boolean;
+}
+
+export function ZoneCountriesTable({ zoneId, canAddCountries = false }: ZoneCountriesTableProps) {
+    const { data, refetch } = useQuery({
+        queryKey: ['zone', zoneId],
+        queryFn: () => api.query(zoneMembersQuery, { zoneId }),
+    });
+
+    const { mutate: addCountryToZone } = useMutation({
+        mutationFn: api.mutate(addCountryToZoneMutation),
+        onSuccess: () => {
+            refetch();
+        },
+    });
+
+    const { mutate: removeCountryFromZone } = useMutation({
+        mutationFn: api.mutate(removeCountryFromZoneMutation),
+        onSuccess: () => {
+            refetch();
+        },
+    });
+
+    const [page, setPage] = useState(1);
+    const [pageSize, setPageSize] = useState(10);
+
+    const paginatedItems = useMemo(() => {
+        return data?.zone?.members?.slice((page - 1) * pageSize, page * pageSize);
+    }, [data, page, pageSize]);
+
+    const columns: ColumnDef<any>[] = [
+        {
+            header: 'Country',
+            accessorKey: 'name',
+        },
+        {
+            header: 'Enabled',
+            accessorKey: 'enabled',
+        },
+        {
+            header: 'Code',
+            accessorKey: 'code',
+        },
+    ];
+
+    return (
+        <div>
+            <DataTable
+                columns={columns}
+                data={paginatedItems ?? []}
+                onPageChange={(table, page, itemsPerPage) => {
+                setPage(page);
+                setPageSize(itemsPerPage);
+            }}
+                totalItems={data?.zone?.members?.length ?? 0}
+            />
+            {canAddCountries && (
+                <CountrySelector
+                    onSelect={country => {
+                        addCountryToZone({
+                            zoneId,
+                            memberIds: [country.id],
+                        });
+                    }}
+                />
+            )}
+        </div>
+    );
+}

+ 44 - 0
packages/dashboard/src/routes/_authenticated/_zones/zones.graphql.ts

@@ -41,3 +41,47 @@ export const zoneMembersQuery = graphql(`
         }
         }
     }
     }
 `);
 `);
+
+export const zoneDetailQuery = graphql(
+    `
+        query ZoneDetail($id: ID!) {
+            zone(id: $id) {
+                ...ZoneItem
+                customFields
+            }
+        }
+    `,
+    [zoneItemFragment],
+);
+
+export const createZoneDocument = graphql(`
+    mutation CreateZone($input: CreateZoneInput!) {
+        createZone(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updateZoneDocument = graphql(`
+    mutation UpdateZone($input: UpdateZoneInput!) {
+        updateZone(input: $input) {
+            id
+        }
+    }
+`);
+
+export const addCountryToZoneMutation = graphql(`
+    mutation AddMembersToZone($zoneId: ID!, $memberIds: [ID!]!) {
+        addMembersToZone(zoneId: $zoneId, memberIds: $memberIds) {
+            id
+        }
+    }
+`);
+
+export const removeCountryFromZoneMutation = graphql(`
+    mutation RemoveMembersFromZone($zoneId: ID!, $memberIds: [ID!]!) {
+        removeMembersFromZone(zoneId: $zoneId, memberIds: $memberIds) {
+            id
+        }
+    }
+`);

+ 17 - 2
packages/dashboard/src/routes/_authenticated/_zones/zones.tsx

@@ -1,10 +1,14 @@
 import { Trans } from '@lingui/react/macro';
 import { Trans } from '@lingui/react/macro';
-import { createFileRoute } from '@tanstack/react-router';
+import { createFileRoute, Link } from '@tanstack/react-router';
 import { ListPage } from '@/framework/page/list-page.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import { zoneListQuery } from './zones.graphql.js';
 import { zoneListQuery } from './zones.graphql.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { ZoneCountriesSheet } from './components/zone-countries-sheet.js';
 import { ZoneCountriesSheet } from './components/zone-countries-sheet.js';
+import { Button } from '@/components/ui/button.js';
+import { PlusIcon } from 'lucide-react';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
 
 
 export const Route = createFileRoute('/_authenticated/_zones/zones')({
 export const Route = createFileRoute('/_authenticated/_zones/zones')({
     component: ZoneListPage,
     component: ZoneListPage,
@@ -36,6 +40,17 @@ function ZoneListPage() {
                     ),
                     ),
                 },
                 },
             }}
             }}
-        />
+        >
+            <PageActionBar>
+                <PermissionGuard requires={['CreateZone']}>
+                    <Button asChild>
+                        <Link to="./new">
+                            <PlusIcon />
+                            <Trans>New Zone</Trans>
+                        </Link>
+                    </Button>
+                </PermissionGuard>
+            </PageActionBar>
+        </ListPage>
     );
     );
 }
 }

+ 140 - 0
packages/dashboard/src/routes/_authenticated/_zones/zones_.$id.tsx

@@ -0,0 +1,140 @@
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.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 { Switch } from '@/components/ui/switch.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 {
+    createZoneDocument,
+    zoneDetailQuery,
+    updateZoneDocument,
+} from './zones.graphql.js';
+import { ZoneCountriesTable } from './components/zone-countries-table.js';
+
+export const Route = createFileRoute('/_authenticated/_zones/zones_/$id')({
+    component: ZoneDetailPage,
+    loader: async ({ context, params }) => {
+        const isNew = params.id === NEW_ENTITY_PATH;
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(addCustomFields(zoneDetailQuery), { id: params.id }),
+                  { id: params.id },
+              );
+        if (!isNew && !result.zone) {
+            throw new Error(`Zone with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: [
+                { path: '/zones', label: 'Zones' },
+                isNew ? <Trans>New zone</Trans> : result.zone.name,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+export function ZoneDetailPage() {
+    const params = Route.useParams();
+    const navigate = useNavigate();
+    const creatingNewEntity = params.id === NEW_ENTITY_PATH;
+    const { i18n } = useLingui();
+
+    const { form, submitHandler, entity, isPending } = useDetailPage({
+        queryDocument: addCustomFields(zoneDetailQuery),
+        entityField: 'zone',
+        createDocument: createZoneDocument,
+        updateDocument: updateZoneDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                name: entity.name,
+                customFields: entity.customFields,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async data => {
+            toast(i18n.t('Successfully updated zone'), {
+                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 zone'), {
+                position: 'top-right',
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    return (
+        <Page>
+            <PageTitle>
+                {creatingNewEntity ? <Trans>New zone</Trans> : (entity?.name ?? '')}
+            </PageTitle>
+            <Form {...form}>
+                <form onSubmit={submitHandler} className="space-y-8">
+                    <PageActionBar>
+                        <div></div>
+                        <PermissionGuard requires={['UpdateZone']}>
+                            <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 gap-4">
+                                <FormField
+                                    control={form.control}
+                                    name="name"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Name</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" {...field} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </div>
+                        </PageBlock>
+                        <CustomFieldsPageBlock
+                            column="main"
+                            entityType="Zone"
+                            control={form.control}
+                        />
+                        {!creatingNewEntity && (
+                            <PageBlock column="main" title={<Trans>Countries</Trans>}>
+                                <ZoneCountriesTable zoneId={entity?.id} canAddCountries={true} />
+                            </PageBlock>
+                        )}
+                    </PageLayout>
+                </form>
+            </Form>
+        </Page>
+    );
+}