Browse Source

feat(dashboard): Introduce local formatting hook and enhance data components

Michael Bromley 10 months ago
parent
commit
dba6d143e4

+ 4 - 4
packages/dashboard/src/components/data-type-components/date-time.tsx

@@ -1,13 +1,13 @@
-import { useLingui } from '@lingui/react/macro';
+import { useLocalFormat } from '@/hooks/use-local-format.js';
 
 export function DateTime({ value }: { value: string | Date }) {
-    const { i18n } = useLingui();
+    const { formatDate } = useLocalFormat();
     let renderedDate: string;
     try {
-        renderedDate = i18n.date(value);
+        renderedDate = formatDate(value);
     } catch (e) {
         renderedDate = value.toString();
         console.error(e);
     }
-    return <div>{renderedDate}</div>;
+    return renderedDate;
 }

+ 6 - 0
packages/dashboard/src/components/data-type-components/money.tsx

@@ -0,0 +1,6 @@
+import { useLocalFormat } from "@/hooks/use-local-format.js";
+
+export function Money({ value, currency }: { value: number, currency: string }) {
+    const { formatCurrency } = useLocalFormat();
+    return formatCurrency(value, currency);
+}

+ 3 - 1
packages/dashboard/src/components/layout/content-language-selector.tsx

@@ -4,6 +4,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
 import { cn } from '@/lib/utils.js';
 import { getLocalizedLanguageName } from '@/lib/locale-utils.js';
 import { useUserSettings } from '@/providers/user-settings.js';
+import { useLocalFormat } from '@/hooks/use-local-format.js';
 
 interface ContentLanguageSelectorProps {
     value?: string;
@@ -13,6 +14,7 @@ interface ContentLanguageSelectorProps {
 
 export function ContentLanguageSelector({ value, onChange, className }: ContentLanguageSelectorProps) {
     const serverConfig = useServerConfig();
+    const { formatLanguageName } = useLocalFormat();
     const { settings: { contentLanguage, displayLanguage }, setContentLanguage} = useUserSettings();
 
     // Fallback to empty array if serverConfig is null
@@ -32,7 +34,7 @@ export function ContentLanguageSelector({ value, onChange, className }: ContentL
             <SelectContent>
                 {languages.map(language => (
                     <SelectItem key={language} value={language}>
-                        {getLocalizedLanguageName(language, displayLanguage)}
+                        {formatLanguageName(language)}
                     </SelectItem>
                 ))}
             </SelectContent>

+ 1 - 1
packages/dashboard/src/components/shared/assigned-facet-values.tsx

@@ -15,7 +15,7 @@ interface FacetValue {
 }
 
 interface AssignedFacetValuesProps {
-    value?: string[];
+    value?: string[] | null;
     facetValues: FacetValue[];
     canUpdate?: boolean;
     onBlur?: () => void;

+ 21 - 0
packages/dashboard/src/components/shared/page-card.tsx

@@ -0,0 +1,21 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.js";
+
+export interface PageCardProps {
+    children: React.ReactNode;
+    title?: string;
+    description?: string;
+}
+
+export function PageCard({ children, title, description }: PageCardProps) {
+    return (
+        <Card>
+            {title || description ? (
+                <CardHeader>
+                    {title && <CardTitle>{title}</CardTitle>}
+                    {description && <CardDescription>{description}</CardDescription>}
+                </CardHeader>
+            ) : null}
+            <CardContent className={!title ? 'pt-6' : ''}>{children}</CardContent>
+        </Card>
+    );
+}

+ 4 - 8
packages/dashboard/src/components/shared/paginated-list-data-table.tsx

@@ -21,6 +21,7 @@ import {
 import { ColumnDef } from '@tanstack/table-core';
 import { ResultOf } from 'gql.tada';
 import React, { useMemo } from 'react';
+import { Delegate } from '@/framework/component-registry/delegate.js';
 
 type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
     [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
@@ -143,19 +144,14 @@ export function PaginatedListDataTable<
                     if (field.list && Array.isArray(value)) {
                         return value.join(', ');
                     }
-                    let Cmp: React.ComponentType<{ value: any }> | undefined = undefined;
                     if ((field.type === 'DateTime' && typeof value === 'string') || value instanceof Date) {
-                        Cmp = getComponent('dateTime.display');
+                        return <Delegate component='dateTime.display' value={value} />
                     }
                     if (field.type === 'Boolean') {
-                        Cmp = getComponent('boolean.display');
+                        return <Delegate component='boolean.display' value={value} />
                     }
                     if (field.type === 'Asset') {
-                        Cmp = getComponent('asset.display');
-                    }
-
-                    if (Cmp) {
-                        return <Cmp value={value} />;
+                        return <Delegate component='asset.display' value={value} />
                     }
                     if (value !== null && typeof value === 'object') {
                         return JSON.stringify(value);

+ 14 - 6
packages/dashboard/src/framework/component-registry/component-registry.tsx

@@ -1,6 +1,7 @@
 import { AssetThumbnail } from '@/components/data-type-components/asset.js';
 import { BooleanDisplayCheckbox } from '@/components/data-type-components/boolean.js';
 import { DateTime } from '@/components/data-type-components/date-time.js';
+import { Money } from '@/components/data-type-components/money.js';
 
 export interface ComponentRegistryEntry {
     component: React.ComponentType<any>;
@@ -39,28 +40,35 @@ export const COMPONENT_REGISTRY = {
                 },
             },
         },
+        money: {
+            display: {
+                default: {
+                    component: Money,
+                },
+            },
+        },
     },
 } satisfies ComponentRegistry;
 
-type TypeRegistry = (typeof COMPONENT_REGISTRY)['type'];
-type TypeRegistryTypes = keyof TypeRegistry;
-type TypeRegistryCategories<T extends TypeRegistryTypes> = {
+export type TypeRegistry = (typeof COMPONENT_REGISTRY)['type'];
+export type TypeRegistryTypes = keyof TypeRegistry;
+export type TypeRegistryCategories<T extends TypeRegistryTypes> = {
     [K in keyof TypeRegistry[T]]: K;
 }[keyof TypeRegistry[T]];
-type TypeRegistryComponents<
+export type TypeRegistryComponents<
     T extends TypeRegistryTypes,
     U extends TypeRegistryCategories<T> = TypeRegistryCategories<T>,
 > = {
     [K in keyof TypeRegistry[T][U]]: K;
 }[keyof TypeRegistry[T][U]];
-type NonDefaultComponents<
+export type NonDefaultComponents<
     T extends TypeRegistryTypes,
     U extends TypeRegistryCategories<T> = TypeRegistryCategories<T>,
 > = {
     [K in TypeRegistryComponents<T, U>]: K extends 'default' ? never : `${T}.${U & string}.${K & string}`;
 }[keyof TypeRegistry[T][U]];
 
-type ComponentTypePath<
+export type ComponentTypePath<
     T extends TypeRegistryTypes,
     U extends TypeRegistryCategories<T> = TypeRegistryCategories<T>,
 > = `${T}.${U & string}` | `${NonDefaultComponents<T, U>}`;

+ 35 - 0
packages/dashboard/src/framework/component-registry/delegate.tsx

@@ -0,0 +1,35 @@
+import React from "react";
+import { ComponentTypePath, TypeRegistryCategories, TypeRegistryTypes, useComponentRegistry } from "./component-registry.js";
+
+export type DelegateProps<
+    T extends TypeRegistryTypes,
+    U extends TypeRegistryCategories<T> = TypeRegistryCategories<T>,
+> = {
+    component: ComponentTypePath<T, TypeRegistryCategories<T>>;
+    value: any;
+    // rest of the props are passed to the component
+    [key: string]: any;
+}
+
+/**
+ * @description
+ * This component is used to delegate the rendering of a component to the component registry.
+ * 
+ * @example
+ * ```ts
+ * <Delegate component="money.display.default" value={100} />
+ * ```
+ * 
+ * @returns 
+ */
+export function Delegate<
+    T extends TypeRegistryTypes,
+    U extends TypeRegistryCategories<T> = TypeRegistryCategories<T>,
+>(props: DelegateProps<T, U>): React.ReactNode {
+    const { getComponent } = useComponentRegistry();
+    const Component = getComponent(props.component);
+    const { value, ...rest } = props;
+    return <Component value={value} {...rest} />;
+}
+
+

+ 102 - 0
packages/dashboard/src/hooks/use-local-format.ts

@@ -0,0 +1,102 @@
+import { useServerConfig } from '@/providers/server-config.js';
+import { useLingui } from '@lingui/react';
+import { useCallback, useMemo } from 'react';
+
+/**
+ * @description
+ * This hook is used to format numbers and currencies using the configured language and
+ * locale of the dashboard app.
+ *
+ * @example
+ * ```ts
+ * const {
+ *          formatCurrency,
+ *          formatNumber,
+ *          formatDate,
+ *          formatLanguageName,
+ *          formatCurrencyName,
+ *          toMajorUnits,
+ * } = useLocalFormat();
+ * ```
+ */
+export function useLocalFormat() {
+    const { i18n } = useLingui();
+    const { moneyStrategyPrecision } = useServerConfig() ?? { moneyStrategyPrecision: 2 };
+    const precisionFactor = useMemo(() => Math.pow(10, moneyStrategyPrecision), [moneyStrategyPrecision]);
+
+    const toMajorUnits = useCallback(
+        (value: number): number => {
+            return value / precisionFactor;
+        },
+        [precisionFactor],
+    );
+
+    const formatCurrency = useCallback(
+        (value: number, currency: string) => {
+            return i18n.number(toMajorUnits(value), {
+                style: 'currency',
+                currency,
+                minimumFractionDigits: moneyStrategyPrecision,
+                maximumFractionDigits: moneyStrategyPrecision,
+            });
+        },
+        [i18n, moneyStrategyPrecision, toMajorUnits],
+    );
+
+    const formatNumber = (value: number) => {
+        return i18n.number(value);
+    };
+
+    const formatDate = (value: string | Date) => {
+        return i18n.date(value);
+    };
+
+    const formatLanguageName = (value: string): string => {
+        try {
+            return (
+                new Intl.DisplayNames([i18n.locale], { type: 'language' }).of(value.replace('_', '-')) ??
+                value
+            );
+        } catch (e: any) {
+            return value;
+        }
+    };
+
+    const formatCurrencyName = useCallback(
+        (currencyCode: string, display: 'full' | 'symbol' | 'name' = 'full'): string => {
+            if (!currencyCode) return '';
+
+            try {
+                const name =
+                    display === 'full' || display === 'name'
+                        ? (new Intl.DisplayNames([i18n.locale], { type: 'currency' }).of(currencyCode) ?? '')
+                        : '';
+
+                const symbol =
+                    display === 'full' || display === 'symbol'
+                        ? (new Intl.NumberFormat(i18n.locale, {
+                              style: 'currency',
+                              currency: currencyCode,
+                              currencyDisplay: 'symbol',
+                          })
+                              .formatToParts()
+                              .find(p => p.type === 'currency')?.value ?? currencyCode)
+                        : '';
+
+                return display === 'full' ? `${name} (${symbol})` : display === 'name' ? name : symbol;
+            } catch (e) {
+                return currencyCode;
+            }
+        },
+        [i18n.locale],
+    );
+
+    return {
+        formatCurrency,
+        formatNumber,
+        formatDate,
+        formatLanguageName,
+        formatCurrencyName,
+        toMajorUnits,
+    };
+}

+ 31 - 0
packages/dashboard/src/routes/_authenticated/_products/components/product-variants-table.tsx

@@ -2,12 +2,15 @@ import { PaginatedListDataTable } from "@/components/shared/paginated-list-data-
 import { productVariantListDocument } from "../products.graphql.js";
 import { useState } from "react";
 import { ColumnFiltersState, SortingState } from "@tanstack/react-table";
+import { Money } from "@/components/data-type-components/money.js";
+import { useLocalFormat } from "@/hooks/use-local-format.js";
 
 interface ProductVariantsTableProps {
     productId: string;
 }
 
 export function ProductVariantsTable({ productId }: ProductVariantsTableProps) {
+    const { formatCurrencyName } = useLocalFormat();
     const [page, setPage] = useState(1);
     const [pageSize, setPageSize] = useState(10);
     const [sorting, setSorting] = useState<SortingState>([]);
@@ -19,6 +22,34 @@ export function ProductVariantsTable({ productId }: ProductVariantsTableProps) {
             ...variables,
             productId,
         })}
+        customizeColumns={{
+            currencyCode: {
+                cell: ({ cell, row }) => {
+                    const value = cell.getValue();
+                    return formatCurrencyName(value as string, 'full');
+                },
+            },
+            price: {
+                cell: ({ cell, row }) => {
+                    const value = cell.getValue();
+                    const currencyCode = row.original.currencyCode;
+                    if (typeof value === 'number') {
+                        return <Money value={value} currency={currencyCode} />;
+                    }
+                    return value;
+                },
+            },
+            priceWithTax: {
+                cell: ({ cell, row }) => {
+                    const value = cell.getValue();
+                    const currencyCode = row.original.currencyCode;
+                    if (typeof value === 'number') {
+                        return <Money value={value} currency={currencyCode} />;
+                    }
+                    return value;
+                },
+            },
+        }}
         page={page}
         itemsPerPage={pageSize}
         sorting={sorting}

+ 1 - 0
packages/dashboard/src/routes/_authenticated/_products/products.graphql.ts

@@ -67,6 +67,7 @@ export const productVariantListDocument = graphql(`
                 id
                 name
                 sku
+                currencyCode
                 price
                 priceWithTax
             }

+ 119 - 127
packages/dashboard/src/routes/_authenticated/_products/products_.$id.tsx

@@ -19,12 +19,13 @@ import { Textarea } from '@/components/ui/textarea.js';
 import { useGeneratedForm } from '@/framework/form-engine/use-generated-form.js';
 import { DetailPage, getDetailQueryOptions } from '@/framework/page/detail-page.js';
 import { api } from '@/graphql/api.js';
-import { Trans } from '@lingui/react/macro';
+import { Trans, useLingui } from '@lingui/react/macro';
 import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
 import { createFileRoute } from '@tanstack/react-router';
 import { toast } from 'sonner';
 import { ProductVariantsTable } from './components/product-variants-table.js';
 import { productDetailDocument, updateProductDocument } from './products.graphql.js';
+import { PageCard } from '@/components/shared/page-card.js';
 
 export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
     component: ProductDetailPage,
@@ -38,6 +39,7 @@ export const Route = createFileRoute('/_authenticated/_products/products_/$id')(
 
 export function ProductDetailPage() {
     const params = Route.useParams();
+    const { i18n } = useLingui();
     const queryClient = useQueryClient();
     const detailQueryOptions = getDetailQueryOptions(productDetailDocument, { id: params.id });
     const detailQuery = useSuspenseQuery(detailQueryOptions);
@@ -96,145 +98,135 @@ export function ProductDetailPage() {
 
                     <div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
                         <div className="lg:col-span-3 flex flex-col gap-4">
-                            <Card className="">
-                                <CardContent className="pt-6">
-                                    <div className="flex flex-col gap-4">
-                                        <div className="md:flex w-full gap-4">
-                                            <div className="w-1/2">
-                                                <TranslatableFormField
-                                                    control={form.control}
-                                                    name="name"
-                                                    render={({ field }) => (
-                                                        <FormItem>
-                                                            <FormLabel>
-                                                                <Trans>Product name</Trans>
-                                                            </FormLabel>
-                                                            <FormControl>
-                                                                <Input placeholder="" {...field} />
-                                                            </FormControl>
-                                                            <FormDescription></FormDescription>
-                                                            <FormMessage />
-                                                        </FormItem>
-                                                    )}
-                                                />
-                                            </div>
-                                            <div className="w-1/2">
-                                                <TranslatableFormField
-                                                    control={form.control}
-                                                    name="slug"
-                                                    render={({ field }) => (
-                                                        <FormItem>
-                                                            <FormLabel>
-                                                                <Trans>Slug</Trans>
-                                                            </FormLabel>
-                                                            <FormControl>
-                                                                <Input placeholder="" {...field} />
-                                                            </FormControl>
-                                                            <FormDescription></FormDescription>
-                                                            <FormMessage />
-                                                        </FormItem>
-                                                    )}
-                                                />
-                                            </div>
+                            <PageCard>
+                                <div className="flex flex-col gap-4">
+                                    <div className="md:flex w-full gap-4">
+                                        <div className="w-1/2">
+                                            <TranslatableFormField
+                                                control={form.control}
+                                                name="name"
+                                                render={({ field }) => (
+                                                    <FormItem>
+                                                        <FormLabel>
+                                                            <Trans>Product name</Trans>
+                                                        </FormLabel>
+                                                        <FormControl>
+                                                            <Input placeholder="" {...field} />
+                                                        </FormControl>
+                                                        <FormDescription></FormDescription>
+                                                        <FormMessage />
+                                                    </FormItem>
+                                                )}
+                                            />
+                                        </div>
+                                        <div className="w-1/2">
+                                            <TranslatableFormField
+                                                control={form.control}
+                                                name="slug"
+                                                render={({ field }) => (
+                                                    <FormItem>
+                                                        <FormLabel>
+                                                            <Trans>Slug</Trans>
+                                                        </FormLabel>
+                                                        <FormControl>
+                                                            <Input placeholder="" {...field} />
+                                                        </FormControl>
+                                                        <FormDescription></FormDescription>
+                                                        <FormMessage />
+                                                    </FormItem>
+                                                )}
+                                            />
                                         </div>
-                                        <TranslatableFormField
-                                            control={form.control}
-                                            name="description"
-                                            render={({ field }) => (
-                                                <FormItem>
-                                                    <FormLabel>
-                                                        <Trans>Description</Trans>
-                                                    </FormLabel>
-                                                    <FormControl>
-                                                        <Textarea className="resize-none" {...field} />
-                                                    </FormControl>
-                                                    <FormDescription></FormDescription>
-                                                    <FormMessage />
-                                                </FormItem>
-                                            )}
-                                        />
                                     </div>
-                                </CardContent>
-                            </Card>
-                            <Card className="">
-                                <CardContent className="pt-6">
-                                    <ProductVariantsTable productId={params.id} />
-                                </CardContent>
-                            </Card>
-                        </div>
-                        <div className="lg:col-span-1 flex flex-col gap-4">
-                            <Card className="">
-                                <CardContent className="pt-6">
-                                    <FormField
+                                    <TranslatableFormField
                                         control={form.control}
-                                        name="enabled"
+                                        name="description"
                                         render={({ field }) => (
                                             <FormItem>
                                                 <FormLabel>
-                                                    <Trans>Enabled</Trans>
+                                                    <Trans>Description</Trans>
                                                 </FormLabel>
                                                 <FormControl>
-                                                    <Switch
-                                                        checked={field.value}
-                                                        onCheckedChange={field.onChange}
-                                                    />
+                                                    <Textarea className="resize-none" {...field} />
                                                 </FormControl>
-                                                <FormDescription>
-                                                    <Trans>
-                                                        When enabled, a product is available in the shop
-                                                    </Trans>
-                                                </FormDescription>
+                                                <FormDescription></FormDescription>
                                                 <FormMessage />
                                             </FormItem>
                                         )}
                                     />
-                                </CardContent>
-                            </Card>
-                            <Card className="">
-                                <CardContent className="pt-6">
-                                    <FormField
-                                        control={form.control}
-                                        name="facetValueIds"
-                                        render={({ field }) => (
-                                            <FormItem>
-                                                <FormLabel>
-                                                    <Trans>Facet values</Trans>
-                                                </FormLabel>
-                                                <FormControl>
-                                                    <AssignedFacetValues
-                                                        facetValues={entity?.facetValues ?? []}
-                                                        {...field}
-                                                    />
-                                                </FormControl>
-                                                <FormMessage />
-                                            </FormItem>
-                                        )}
-                                    />
-                                </CardContent>
-                            </Card>
-                            <Card className="">
-                                <CardContent className="pt-6">
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Assets</Trans>
-                                        </FormLabel>
-                                        <FormControl>
-                                            <EntityAssets
-                                                assets={entity?.assets}
-                                                featuredAsset={entity?.featuredAsset}
-                                                compact={true}
-                                                value={form.getValues()}
-                                                onChange={value => {
-                                                    form.setValue('featuredAssetId', value.featuredAssetId);
-                                                    form.setValue('assetIds', value.assetIds);
-                                                }}
-                                            />
-                                        </FormControl>
-                                        <FormDescription></FormDescription>
-                                        <FormMessage />
-                                    </FormItem>
-                                </CardContent>
-                            </Card>
+                                </div>
+                            </PageCard>
+                            <PageCard title={i18n.t('Product variants')}>
+                                <ProductVariantsTable productId={params.id} />
+                            </PageCard>
+                        </div>
+                        <div className="lg:col-span-1 flex flex-col gap-4">
+                            <PageCard>
+                                <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>
+                                                    When enabled, a product is available in the shop
+                                                </Trans>
+                                            </FormDescription>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </PageCard>
+                            <PageCard>
+                                <FormField
+                                    control={form.control}
+                                    name="facetValueIds"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Facet values</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <AssignedFacetValues
+                                                    facetValues={entity?.facetValues ?? []}
+                                                    {...field}
+                                                />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </PageCard>
+                            <PageCard>
+                                <FormItem>
+                                    <FormLabel>
+                                        <Trans>Assets</Trans>
+                                    </FormLabel>
+                                    <FormControl>
+                                        <EntityAssets
+                                            assets={entity?.assets}
+                                            featuredAsset={entity?.featuredAsset}
+                                            compact={true}
+                                            value={form.getValues()}
+                                            onChange={value => {
+                                                form.setValue('featuredAssetId', value.featuredAssetId);
+                                                form.setValue('assetIds', value.assetIds);
+                                            }}
+                                        />
+                                    </FormControl>
+                                    <FormDescription></FormDescription>
+                                    <FormMessage />
+                                </FormItem>
+                            </PageCard>
                         </div>
                     </div>
                 </form>