Browse Source

feat(dashboard): Store user settings, improve language handling

Michael Bromley 10 months ago
parent
commit
abff0cd2f8

+ 4 - 1
packages/dashboard/src/app-providers.tsx

@@ -5,6 +5,7 @@ import { routeTree } from '@/routeTree.gen.js';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { createRouter } from '@tanstack/react-router';
 import React from 'react';
+import { UserSettingsProvider } from './providers/user-settings.js';
 
 export const queryClient = new QueryClient();
 
@@ -31,7 +32,9 @@ export function AppProviders({ children }: { children: React.ReactNode }) {
         <I18nProvider>
             <QueryClientProvider client={queryClient}>
                 <ServerConfigProvider>
-                    <AuthProvider>{children}</AuthProvider>
+                    <UserSettingsProvider>
+                        <AuthProvider>{children}</AuthProvider>
+                    </UserSettingsProvider>
                 </ServerConfigProvider>
             </QueryClientProvider>
         </I18nProvider>

+ 13 - 7
packages/dashboard/src/components/content-language-selector.tsx

@@ -1,32 +1,38 @@
 import * as React from 'react';
-import { useServerConfig } from '@/providers/server-config';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
-import { cn } from '@/lib/utils';
+import { useServerConfig } from '@/providers/server-config.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
+import { cn } from '@/lib/utils.js';
+import { getLocalizedLanguageName } from '@/lib/locale-utils.js';
+import { useUserSettings } from '@/providers/user-settings.js';
 
 interface ContentLanguageSelectorProps {
     value?: string;
-    onChange: (value: string) => void;
+    onChange?: (value: string) => void;
     className?: string;
 }
 
 export function ContentLanguageSelector({ value, onChange, className }: ContentLanguageSelectorProps) {
     const serverConfig = useServerConfig();
+    const { settings: { contentLanguage, displayLanguage }, setContentLanguage} = useUserSettings();
 
     // Fallback to empty array if serverConfig is null
     const languages = serverConfig?.availableLanguages || [];
 
     // If no value is provided but languages are available, use the first language
-    const currentValue = value || (languages.length > 0 ? languages[0] : '');
+    const currentValue = contentLanguage;
 
     return (
-        <Select value={currentValue} onValueChange={onChange}>
+        <Select value={currentValue} onValueChange={value => {
+            onChange?.(value);
+            setContentLanguage(value);
+            }}>
             <SelectTrigger className={cn('w-[200px]', className)}>
                 <SelectValue placeholder="Select language" />
             </SelectTrigger>
             <SelectContent>
                 {languages.map(language => (
                     <SelectItem key={language} value={language}>
-                        {language}
+                        {getLocalizedLanguageName(language, displayLanguage)}
                     </SelectItem>
                 ))}
             </SelectContent>

+ 3 - 3
packages/dashboard/src/components/ui/form.tsx

@@ -13,6 +13,7 @@ import {
 
 import { cn } from '@/lib/utils';
 import { Label } from '@/components/ui/label';
+import { useUserSettings } from '@/providers/user-settings.js';
 
 const Form = FormProvider;
 
@@ -44,14 +45,13 @@ const TranslatableFormField = <
     TFieldValues extends FieldValues & { translations?: Array<{ languageCode: string }> | null } = FieldValues,
 >({
     name,
-    languageCode,
     ...props
 }: Omit<ControllerProps<TFieldValues>, 'name'> & { 
     name: keyof Omit<NonNullable<TFieldValues['translations']>[number], 'languageCode'>;
-    languageCode: string; 
 }) => {
+    const { contentLanguage } = useUserSettings().settings;
     const index = props.control?._formValues?.translations?.findIndex(
-        (translation: any) => translation?.languageCode === languageCode,
+        (translation: any) => translation?.languageCode === contentLanguage,
     );
     if (index === undefined || index === -1) {
         return null;

+ 1 - 1
packages/dashboard/src/components/ui/input.tsx

@@ -1,6 +1,6 @@
 import * as React from 'react';
 
-import { cn } from '@/lib/utils';
+import { cn } from '@/lib/utils.js';
 
 function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
     return (

+ 18 - 0
packages/dashboard/src/components/ui/textarea.tsx

@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+  return (
+    <textarea
+      data-slot="textarea"
+      className={cn(
+        "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Textarea }

+ 56 - 3
packages/dashboard/src/framework/internal/form-engine/use-generated-form.tsx

@@ -3,6 +3,7 @@ import {
     createFormSchemaFromFields,
     getDefaultValuesFromFields,
 } from '@/framework/internal/form-engine/form-schema-tools.js';
+import { useServerConfig } from '@/providers/server-config.js';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { zodResolver } from '@hookform/resolvers/zod';
 import { VariablesOf } from 'gql.tada';
@@ -17,6 +18,7 @@ type MapToFormField<T> =
         : T extends object
           ? { [K in keyof Required<T>]: MapToFormField<NonNullable<T[K]>> }
           : FormField;
+
 // Define InputFormField that takes a TypedDocumentNode and the name of the input variable
 type InputFormField<
     T extends TypedDocumentNode<any, any>,
@@ -26,7 +28,7 @@ type InputFormField<
 export function useGeneratedForm<
     T extends TypedDocumentNode<any, any>,
     VarName extends keyof VariablesOf<T> = 'input',
-    E = Record<string, any>,
+    E extends Record<string, any> = Record<string, any>,
 >(options: {
     document: T;
     entity: E | null | undefined;
@@ -34,16 +36,19 @@ export function useGeneratedForm<
     onSubmit?: (values: VariablesOf<T>[VarName]) => void;
 }): {
     form: UseFormReturn<VariablesOf<T>[VarName]>;
-    submitHandler: (event: FormEvent) => (values: VariablesOf<T>[VarName]) => void;
+    submitHandler: (event: FormEvent) => void;
 } {
     const { document, entity, setValues, onSubmit } = options;
+    const availableLanguages = useServerConfig()?.availableLanguages || [];
     const updateFields = getOperationVariablesFields(document);
     const schema = createFormSchemaFromFields(updateFields);
     const defaultValues = getDefaultValuesFromFields(updateFields);
+    const processedEntity = ensureTranslationsForAllLanguages(entity, availableLanguages);
+    
     const form = useForm({
         resolver: zodResolver(schema),
         defaultValues,
-        values: entity ? setValues(entity) : defaultValues,
+        values: processedEntity ? setValues(processedEntity) : defaultValues,
     });
     let submitHandler = (event: FormEvent) => {
         event.preventDefault();
@@ -56,3 +61,51 @@ export function useGeneratedForm<
 
     return { form, submitHandler };
 }
+
+
+/**
+ * Ensures that an entity with translations has entries for all available languages.
+ * If a language is missing, it creates an empty translation based on the structure of existing translations.
+ */
+function ensureTranslationsForAllLanguages<E extends Record<string, any>>(
+    entity: E | null | undefined,
+    availableLanguages: string[] = []
+): E | null | undefined {
+    if (!entity || !('translations' in entity) || !Array.isArray((entity as any).translations) || !availableLanguages.length) {
+        return entity;
+    }
+
+    // Create a deep copy of the entity to avoid mutation
+    const processedEntity = { ...entity } as any;
+    const translations = [...(processedEntity.translations || [])];
+    
+    // Get existing language codes
+    const existingLanguageCodes = new Set(
+        translations.map((t: any) => t.languageCode)
+    );
+    
+    // Add missing language translations
+    for (const langCode of availableLanguages) {
+        if (!existingLanguageCodes.has(langCode)) {
+            // Find a translation to use as template for field structure
+            const template = translations[0] || {};
+            const emptyTranslation: Record<string, any> = { 
+                languageCode: langCode 
+            };
+            
+            // Add empty fields based on template (excluding languageCode)
+            Object.keys(template).forEach(key => {
+                if (key !== 'languageCode') {
+                    emptyTranslation[key] = '';
+                }
+            });
+            
+            translations.push(emptyTranslation);
+        }
+    }
+    
+    // Update the processed entity with complete translations
+    processedEntity.translations = translations;
+    
+    return processedEntity as E;
+}

+ 1 - 1
packages/dashboard/src/framework/internal/page/detail-page.tsx

@@ -26,7 +26,7 @@ export interface DetailPageProps extends PageProps {
 export function DetailPage({ title, entity, children }: DetailPageProps) {
     return (
         <div>
-            <h1 className="text-2xl font-bold">{title}</h1>
+            <h1 className="text-2xl font-bold mb-4">{title}</h1>
             {children}
         </div>
     );

+ 11 - 0
packages/dashboard/src/lib/locale-utils.ts

@@ -0,0 +1,11 @@
+export function getLocalizedLanguageName(value: string, locale: string): string {
+    try {
+        return (
+            new Intl.DisplayNames([locale.replace('_', '-')], { type: 'language' }).of(
+                value.replace('_', '-'),
+            ) ?? value
+        );
+    } catch (e: any) {
+        return value;
+    }
+}

+ 1 - 1
packages/dashboard/src/providers/server-config.tsx

@@ -259,7 +259,7 @@ export interface ServerConfig {
 }
 
 // create a provider for the global settings
-export const ServerConfigProvider = ({ children }: { childred: React.ReactNode }) => {
+export const ServerConfigProvider = ({ children }: { children: React.ReactNode }) => {
     const { data } = useQuery({
         queryKey: ['getServerConfig'],
         queryFn: () => api.query(getServerConfigDocument),

+ 89 - 0
packages/dashboard/src/providers/user-settings.tsx

@@ -0,0 +1,89 @@
+import React, { createContext, useContext, useState, useEffect } from 'react';
+
+export interface UserSettings {
+  displayLanguage: string;
+  displayLocale: string | null;
+  contentLanguage: string;
+  theme: string;
+  displayUiExtensionPoints: boolean;
+  mainNavExpanded: boolean;
+}
+
+const defaultSettings: UserSettings = {
+  displayLanguage: 'en',
+  displayLocale: null,
+  contentLanguage: 'en',
+  theme: 'default',
+  displayUiExtensionPoints: false,
+  mainNavExpanded: true,
+};
+
+interface UserSettingsContextType {
+  settings: UserSettings;
+  setDisplayLanguage: (language: string) => void;
+  setDisplayLocale: (locale: string | null) => void;
+  setContentLanguage: (language: string) => void;
+  setTheme: (theme: string) => void;
+  setDisplayUiExtensionPoints: (display: boolean) => void;
+  setMainNavExpanded: (expanded: boolean) => void;
+}
+
+const UserSettingsContext = createContext<UserSettingsContextType | undefined>(undefined);
+
+const STORAGE_KEY = 'vendure-user-settings';
+
+export const UserSettingsProvider: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
+  // Load settings from localStorage or use defaults
+  const loadSettings = (): UserSettings => {
+    try {
+      const storedSettings = localStorage.getItem(STORAGE_KEY);
+      if (storedSettings) {
+        return { ...defaultSettings, ...JSON.parse(storedSettings) };
+      }
+    } catch (e) {
+      console.error('Failed to load user settings from localStorage', e);
+    }
+    return { ...defaultSettings };
+  };
+
+  const [settings, setSettings] = useState<UserSettings>(loadSettings);
+
+  // Save settings to localStorage whenever they change
+  useEffect(() => {
+    try {
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
+    } catch (e) {
+      console.error('Failed to save user settings to localStorage', e);
+    }
+  }, [settings]);
+
+  // Settings updaters
+  const updateSetting = <K extends keyof UserSettings>(key: K, value: UserSettings[K]) => {
+    setSettings(prev => ({ ...prev, [key]: value }));
+  };
+
+  const contextValue: UserSettingsContextType = {
+    settings,
+    setDisplayLanguage: (language) => updateSetting('displayLanguage', language),
+    setDisplayLocale: (locale) => updateSetting('displayLocale', locale),
+    setContentLanguage: (language) => updateSetting('contentLanguage', language),
+    setTheme: (theme) => updateSetting('theme', theme),
+    setDisplayUiExtensionPoints: (display) => updateSetting('displayUiExtensionPoints', display),
+    setMainNavExpanded: (expanded) => updateSetting('mainNavExpanded', expanded),
+  };
+
+  return (
+    <UserSettingsContext.Provider value={contextValue}>
+      {children}
+    </UserSettingsContext.Provider>
+  );
+};
+
+// Hook to use the user settings
+export const useUserSettings = () => {
+  const context = useContext(UserSettingsContext);
+  if (context === undefined) {
+    throw new Error('useUserSettings must be used within a UserSettingsProvider');
+  }
+  return context;
+};

+ 16 - 6
packages/dashboard/src/routes/_authenticated/products_.$id.tsx

@@ -13,6 +13,7 @@ import {
 } 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 { useGeneratedForm } from '@/framework/internal/form-engine/use-generated-form.js';
 import { DetailPage, getDetailQueryOptions } from '@/framework/internal/page/detail-page.js';
 import { api } from '@/graphql/api.js';
@@ -95,9 +96,6 @@ export function ProductDetailPage() {
             console.error(err);
         },
     });
-    const [contentLanguage, setContentLanguage] = React.useState('en');
-
-    const serverConfig = useServerConfig();
 
     const { form, submitHandler } = useGeneratedForm({
         document: updateProductDocument,
@@ -122,7 +120,7 @@ export function ProductDetailPage() {
 
     return (
         <DetailPage title={entity?.name ?? ''} route={Route} entity={entity}>
-            <ContentLanguageSelector value={contentLanguage} onChange={setContentLanguage} />
+            <ContentLanguageSelector className="mb-4" />
             <Form {...form}>
                 <form onSubmit={submitHandler} className="space-y-8">
                     <Card className="">
@@ -159,7 +157,6 @@ export function ProductDetailPage() {
                             <TranslatableFormField
                                 control={form.control}
                                 name="name"
-                                languageCode={contentLanguage}
                                 render={({ field }) => (
                                     <FormItem>
                                         <FormLabel>name</FormLabel>
@@ -174,7 +171,6 @@ export function ProductDetailPage() {
                             <TranslatableFormField
                                 control={form.control}
                                 name="slug"
-                                languageCode={contentLanguage}
                                 render={({ field }) => (
                                     <FormItem>
                                         <FormLabel>Slug</FormLabel>
@@ -186,6 +182,20 @@ export function ProductDetailPage() {
                                     </FormItem>
                                 )}
                             />
+                            <TranslatableFormField
+                                control={form.control}
+                                name="description"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>Description</FormLabel>
+                                        <FormControl>
+                                            <Textarea className="resize-none" {...field} />
+                                        </FormControl>
+                                        <FormDescription></FormDescription>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
                         </CardContent>
                     </Card>
                     <Button type="submit">Submit</Button>