diff --git a/apps/dev-tool/app/emails/[id]/page.tsx b/apps/dev-tool/app/emails/[id]/page.tsx index 8b4df071c..aac617606 100644 --- a/apps/dev-tool/app/emails/[id]/page.tsx +++ b/apps/dev-tool/app/emails/[id]/page.tsx @@ -34,24 +34,23 @@ export default async function EmailPage(props: EmailPageProps) { const template = await loadEmailTemplate(id); const emailSettings = await getEmailSettings(mode); + const values: Record = { + emails: 'Emails', + 'invite-email': 'Invite Email', + 'account-delete-email': 'Account Delete Email', + 'confirm-email': 'Confirm Email', + 'change-email-address-email': 'Change Email Address Email', + 'reset-password-email': 'Reset Password Email', + 'magic-link-email': 'Magic Link Email', + 'otp-email': 'OTP Email', + }; + return ( - } + title={values[id]} + description={} > diff --git a/apps/dev-tool/app/emails/page.tsx b/apps/dev-tool/app/emails/page.tsx index 7718c662c..0661160fb 100644 --- a/apps/dev-tool/app/emails/page.tsx +++ b/apps/dev-tool/app/emails/page.tsx @@ -15,7 +15,11 @@ export const metadata = { export default async function EmailsPage() { return ( - +
diff --git a/apps/dev-tool/app/lib/connectivity-service.ts b/apps/dev-tool/app/lib/connectivity-service.ts index 23221b6c6..921674e5c 100644 --- a/apps/dev-tool/app/lib/connectivity-service.ts +++ b/apps/dev-tool/app/lib/connectivity-service.ts @@ -31,25 +31,32 @@ class ConnectivityService { }; } - const response = await fetch(`${url}/auth/v1/health`, { - headers: { - apikey: anonKey, - Authorization: `Bearer ${anonKey}`, - }, - }); + try { + const response = await fetch(`${url}/auth/v1/health`, { + headers: { + apikey: anonKey, + Authorization: `Bearer ${anonKey}`, + }, + }); - if (!response.ok) { + if (!response.ok) { + return { + status: 'error' as const, + message: + 'Failed to connect to Supabase. The Supabase Anon Key or URL is not valid.', + }; + } + + return { + status: 'success' as const, + message: 'Connected to Supabase', + }; + } catch (error) { return { status: 'error' as const, - message: - 'Failed to connect to Supabase. The Supabase Anon Key or URL is not valid.', + message: `Failed to connect to Supabase. ${error}`, }; } - - return { - status: 'success' as const, - message: 'Connected to Supabase', - }; } async checkSupabaseAdminConnectivity() { @@ -85,35 +92,42 @@ class ConnectivityService { }; } - const response = await fetch(endpoint, { - headers: { - apikey, - Authorization: `Bearer ${adminKey}`, - }, - }); + try { + const response = await fetch(endpoint, { + headers: { + apikey, + Authorization: `Bearer ${adminKey}`, + }, + }); - if (!response.ok) { + if (!response.ok) { + return { + status: 'error' as const, + message: + 'Failed to connect to Supabase Admin. The Supabase Service Role Key is not valid.', + }; + } + + const data = await response.json(); + + if (data.length === 0) { + return { + status: 'error' as const, + message: + 'No accounts found in Supabase Admin. The data may not be seeded. Please run `pnpm run supabase:web:reset` to reset the database.', + }; + } + + return { + status: 'success' as const, + message: 'Connected to Supabase Admin', + }; + } catch (error) { return { status: 'error' as const, - message: - 'Failed to connect to Supabase Admin. The Supabase Service Role Key is not valid.', + message: `Failed to connect to Supabase Admin. ${error}`, }; } - - const data = await response.json(); - - if (data.length === 0) { - return { - status: 'error' as const, - message: - 'No accounts found in Supabase Admin. The data may not be seeded. Please run `pnpm run supabase:web:reset` to reset the database.', - }; - } - - return { - status: 'success' as const, - message: 'Connected to Supabase Admin', - }; } async checkStripeWebhookEndpoints() { diff --git a/apps/dev-tool/app/page.tsx b/apps/dev-tool/app/page.tsx index d4ee3332c..c66da717d 100644 --- a/apps/dev-tool/app/page.tsx +++ b/apps/dev-tool/app/page.tsx @@ -26,6 +26,7 @@ export default async function DashboardPage(props: DashboardPageProps) { diff --git a/apps/dev-tool/app/translations/components/translations-comparison.tsx b/apps/dev-tool/app/translations/components/translations-comparison.tsx index 03e6b49c4..7d051b01d 100644 --- a/apps/dev-tool/app/translations/components/translations-comparison.tsx +++ b/apps/dev-tool/app/translations/components/translations-comparison.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ChevronDownIcon } from 'lucide-react'; +import { ChevronDownIcon, Loader2Icon } from 'lucide-react'; +import { Subject, debounceTime } from 'rxjs'; import { Button } from '@kit/ui/button'; import { @@ -11,6 +12,7 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@kit/ui/dropdown-menu'; +import { If } from '@kit/ui/if'; import { Input } from '@kit/ui/input'; import { Select, @@ -19,6 +21,7 @@ import { SelectTrigger, SelectValue, } from '@kit/ui/select'; +import { toast } from '@kit/ui/sonner'; import { Table, TableBody, @@ -30,6 +33,10 @@ import { import { cn } from '@kit/ui/utils'; import { defaultI18nNamespaces } from '../../../../web/lib/i18n/i18n.settings'; +import { + translateWithAIAction, + updateTranslationAction, +} from '../lib/server-actions'; import type { TranslationData, Translations } from '../lib/translations-loader'; function flattenTranslations( @@ -58,31 +65,37 @@ export function TranslationsComparison({ translations: Translations; }) { const [search, setSearch] = useState(''); - const [selectedLocales, setSelectedLocales] = useState>(); + const [isTranslating, setIsTranslating] = useState(false); const [selectedNamespace, setSelectedNamespace] = useState( defaultI18nNamespaces[0] as string, ); + // Create RxJS Subject for handling translation updates + const subject$ = useMemo( + () => + new Subject<{ + locale: string; + namespace: string; + key: string; + value: string; + }>(), + [], + ); + const locales = Object.keys(translations); - - if (locales.length === 0) { - return
No translations found
; - } - const baseLocale = locales[0]!; - // Initialize selected locales if not set - if (!selectedLocales) { - setSelectedLocales(new Set(locales)); - return null; - } + const [selectedLocales, setSelectedLocales] = useState>( + new Set(locales), + ); // Flatten translations for the selected namespace const flattenedTranslations: FlattenedTranslations = {}; for (const locale of locales) { const namespaceData = translations[locale]?.[selectedNamespace]; + if (namespaceData) { flattenedTranslations[locale] = flattenTranslations(namespaceData); } else { @@ -105,14 +118,6 @@ export function TranslationsComparison({ selectedLocales.has(locale), ); - const copyTranslation = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - } catch (error) { - console.error('Failed to copy text:', error); - } - }; - const toggleLocale = (locale: string) => { const newSelectedLocales = new Set(selectedLocales); @@ -127,58 +132,152 @@ export function TranslationsComparison({ setSelectedLocales(newSelectedLocales); }; + const handleTranslateWithAI = useCallback(async () => { + try { + setIsTranslating(true); + + // Get missing translations for the selected namespace + const missingTranslations: Record = {}; + const baseTranslations = flattenedTranslations[baseLocale] ?? {}; + + for (const locale of visibleLocales) { + if (locale === baseLocale) continue; + + const localeTranslations = flattenedTranslations[locale] ?? {}; + + for (const [key, value] of Object.entries(baseTranslations)) { + if (!localeTranslations[key]) { + missingTranslations[key] = value; + } + } + + if (Object.keys(missingTranslations).length > 0) { + await translateWithAIAction({ + sourceLocale: baseLocale, + targetLocale: locale, + namespace: selectedNamespace, + translations: missingTranslations, + }); + + toast.success(`Translated missing strings to ${locale}`); + } + } + } catch (error) { + toast.error('Failed to translate: ' + (error as Error).message); + } finally { + setIsTranslating(false); + } + }, [flattenedTranslations, baseLocale, visibleLocales, selectedNamespace]); + + // Calculate if there are any missing translations + const hasMissingTranslations = useMemo(() => { + if (!flattenedTranslations || !baseLocale || !visibleLocales) return false; + + const baseTranslations = flattenedTranslations[baseLocale] ?? {}; + + return visibleLocales.some((locale) => { + if (locale === baseLocale) return false; + + const localeTranslations = flattenedTranslations[locale] ?? {}; + + return Object.keys(baseTranslations).some( + (key) => !localeTranslations[key], + ); + }); + }, [flattenedTranslations, baseLocale, visibleLocales]); + + // Set up subscription to handle debounced updates + useEffect(() => { + const subscription = subject$.pipe(debounceTime(500)).subscribe((props) => { + updateTranslationAction(props) + .then(() => { + toast.success(`Updated translation for ${props.key}`); + }) + .catch((err) => { + toast.error(`Failed to update translation: ${err.message}`); + }); + }); + + return () => subscription.unsubscribe(); + }, [subject$]); + + if (locales.length === 0) { + return
No translations found
; + } + return ( -
-
-
- setSearch(e.target.value)} - className="max-w-sm" - /> +
+
+
+
+ setSearch(e.target.value)} + className="max-w-sm" + /> - - - - + 1}> + + + + - - {locales.map((locale) => ( - toggleLocale(locale)} - disabled={ - selectedLocales.size === 1 && selectedLocales.has(locale) - } - > - {locale} - - ))} - - + + {locales.map((locale) => ( + toggleLocale(locale)} + disabled={ + selectedLocales.size === 1 && + selectedLocales.has(locale) + } + > + {locale} + + ))} + + + - + + + - - {defaultI18nNamespaces.map((namespace: string) => ( - - {namespace} - - ))} - - + + {defaultI18nNamespaces.map((namespace: string) => ( + + {namespace} + + ))} + + +
+ +
+ +
@@ -196,7 +295,7 @@ export function TranslationsComparison({ {filteredKeys.map((key) => ( - +
{key}
@@ -222,11 +321,33 @@ export function TranslationsComparison({ })} >
- - {value || ( - Missing - )} - + { + const value = e.target.value.trim(); + + if (value === '') { + toast.error('Translation cannot be empty'); + + return; + } + + if (value === baseValue) { + toast.info('Translation is the same as base'); + + return; + } + + subject$.next({ + locale, + namespace: selectedNamespace, + key, + value, + }); + }} + className="w-full font-mono text-sm" + placeholder={isMissing ? 'Missing translation' : ''} + />
); diff --git a/apps/dev-tool/app/translations/lib/server-actions.ts b/apps/dev-tool/app/translations/lib/server-actions.ts new file mode 100644 index 000000000..821e9364b --- /dev/null +++ b/apps/dev-tool/app/translations/lib/server-actions.ts @@ -0,0 +1,161 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { openai } from '@ai-sdk/openai'; +import { generateText } from 'ai'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:url'; +import { z } from 'zod'; + +import { getLogger } from '@kit/shared/logger'; + +const Schema = z.object({ + locale: z.string().min(1), + namespace: z.string().min(1), + key: z.string().min(1), + value: z.string(), +}); + +const TranslateSchema = z.object({ + sourceLocale: z.string(), + targetLocale: z.string(), + namespace: z.string(), + translations: z.record(z.string(), z.string()), +}); + +/** + * Update a translation value in the specified locale and namespace. + * @param props + */ +export async function updateTranslationAction(props: z.infer) { + // Validate the input + const { locale, namespace, key, value } = Schema.parse(props); + + const root = resolve(process.cwd(), '..'); + const filePath = `${root}apps/web/public/locales/${locale}/${namespace}.json`; + + try { + // Read the current translations file + const translationsFile = readFileSync(filePath, 'utf-8'); + const translations = JSON.parse(translationsFile) as Record; + + // Update the nested key value + const keys = key.split('.') as string[]; + let current = translations; + + // Navigate through nested objects until the second-to-last key + for (let i = 0; i < keys.length - 1; i++) { + const currentKey = keys[i] as string; + + if (!current[currentKey]) { + current[currentKey] = {}; + } + + current = current[currentKey]; + } + + // Set the value at the final key + const finalKey = keys[keys.length - 1] as string; + current[finalKey] = value; + + // Write the updated translations back to the file + writeFileSync(filePath, JSON.stringify(translations, null, 2), 'utf-8'); + + revalidatePath(`/translations`); + + return { success: true }; + } catch (error) { + console.error('Failed to update translation:', error); + throw new Error('Failed to update translation'); + } +} + +export const translateWithAIAction = async ( + data: z.infer, +) => { + const logger = await getLogger(); + + z.string().min(1).parse(process.env.OPENAI_API_KEY); + + try { + const { sourceLocale, targetLocale, namespace, translations } = + TranslateSchema.parse(data); + + // if the path does not exist, create it using an empty object + const root = resolve(process.cwd(), '..'); + const folderPath = `${root}apps/web/public/locales/${targetLocale}`; + + if (!existsSync(folderPath)) { + // create the directory if it doesn't exist + mkdirSync(folderPath, { recursive: true }); + } + + const filePath = `${folderPath}/${namespace}.json`; + + if (!existsSync(filePath)) { + // create the file if it doesn't exist + writeFileSync(filePath, JSON.stringify({}, null, 2), 'utf-8'); + } + + const results: Record = {}; + + // Process translations in batches of 5 for efficiency + const entries = Object.entries(translations); + const batches = []; + + for (let i = 0; i < entries.length; i += 5) { + batches.push(entries.slice(i, i + 5)); + } + + for (const batch of batches) { + const batchPromises = batch.map(async ([key, value]) => { + const prompt = `Translate the following text from ${sourceLocale} to ${targetLocale}. Maintain any placeholders (like {name} or %{count}) and HTML tags. Only return the translated text, nothing else. + +Original text: ${value}`; + + const MODEL_NAME = process.env.LLM_MODEL_NAME ?? 'gpt-4o-mini'; + const model = openai(MODEL_NAME); + + const { text } = await generateText({ + model, + prompt, + temperature: 0.3, + maxTokens: 200, + }); + + return [key, text.trim()] as [string, string]; + }); + + const batchResults = await Promise.all(batchPromises); + + for (const [key, translation] of batchResults) { + results[key] = translation; + } + } + + // Update each translation + for (const [key, translation] of Object.entries(results)) { + await updateTranslationAction({ + locale: targetLocale, + namespace, + key, + value: translation, + }); + } + + logger.info('AI translation completed', { + sourceLocale, + targetLocale, + namespace, + count: Object.keys(results).length, + }); + + revalidatePath('/translations'); + + return { success: true, translations: results }; + } catch (error) { + logger.error('AI translation failed', { error }); + throw error; + } +}; diff --git a/apps/dev-tool/app/translations/page.tsx b/apps/dev-tool/app/translations/page.tsx index 3838f42f1..e11dcb057 100644 --- a/apps/dev-tool/app/translations/page.tsx +++ b/apps/dev-tool/app/translations/page.tsx @@ -18,12 +18,9 @@ export default async function TranslationsPage() { + 'Compare translations across different languages. Ensure consistency and accuracy in your translations.' } /> diff --git a/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx b/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx index ab861c4f6..0d782d228 100644 --- a/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx +++ b/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx @@ -1,21 +1,23 @@ 'use client'; -import { Fragment, useCallback, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { envVariables } from '@/app/variables/lib/env-variables-model'; +import { updateEnvironmentVariableAction } from '@/app/variables/lib/server-actions'; import { EnvModeSelector } from '@/components/env-mode-selector'; import { - ChevronDown, - ChevronUp, ChevronsUpDownIcon, Copy, + CopyIcon, Eye, EyeOff, EyeOffIcon, InfoIcon, } from 'lucide-react'; +import { Subject, debounceTime } from 'rxjs'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Badge } from '@kit/ui/badge'; @@ -39,32 +41,77 @@ import { import { cn } from '@kit/ui/utils'; import { AppEnvState, EnvVariableState } from '../lib/types'; - -type ValidationResult = { - success: boolean; - error?: { - issues: Array<{ message: string }>; - }; -}; +import { DynamicFormInput } from './dynamic-form-input'; export function AppEnvironmentVariablesManager({ state, }: React.PropsWithChildren<{ state: AppEnvState; }>) { - return ( -
- Application: {state.appName} + return ; +} -
- +function EnvListDisplay({ + groups, + className, + hideSecret = false, +}: { + groups: Array<{ + category: string; + variables: Array; + }>; + + className: string; + hideSecret?: boolean; +}) { + return ( +
+
+
+
+ {groups.map((group) => ( +
+ # {group.category} + + {group.variables.map((variable) => { + const model = envVariables.find( + (item) => item.name === variable.key, + ); + + const isSecret = model?.secret; + const value = + isSecret && hideSecret + ? '••••••••' + : variable.effectiveValue; + + return ( + + {variable.key}: {value} + + ); + })} +
+ ))} +
+
); } function EnvList({ appState }: { appState: AppEnvState }) { - const [expandedVars, setExpandedVars] = useState>({}); const [showValues, setShowValues] = useState>({}); const [search, setSearch] = useState(''); const searchParams = useSearchParams(); @@ -75,13 +122,6 @@ function EnvList({ appState }: { appState: AppEnvState }) { const showOverriddenVars = searchParams.get('overridden') === 'true'; const showInvalidVars = searchParams.get('invalid') === 'true'; - const toggleExpanded = (key: string) => { - setExpandedVars((prev) => ({ - ...prev, - [key]: !prev[key], - })); - }; - const toggleShowValue = (key: string) => { setShowValues((prev) => ({ ...prev, @@ -89,175 +129,86 @@ function EnvList({ appState }: { appState: AppEnvState }) { })); }; - const copyToClipboard = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - } catch (err) { - console.error('Failed to copy:', err); - } - }; - const renderValue = (value: string, isVisible: boolean) => { if (!isVisible) { + if (!value) { + return `(empty)`; + } + return '••••••••'; } - return value || '(empty)'; + return value; }; const allVariables = getEffectiveVariablesValue(appState); - // Create a map of all variables including missing ones that have contextual validation - const allVarsWithValidation = envVariables.reduce< - Record - >((acc, model) => { - // If the variable exists in appState, use that - const existingVar = appState.variables[model.name]; - if (existingVar) { - acc[model.name] = existingVar; - } else if ( - // Show missing variables if they: - model.required || // Are marked as required - model.contextualValidation // OR have contextual validation - ) { - // If it doesn't exist but is required or has contextual validation, create an empty state - acc[model.name] = { - key: model.name, - effectiveValue: '', - effectiveSource: 'MISSING', - category: model.category, - isOverridden: false, - definitions: [], - }; - } - return acc; - }, {}); + const subject$ = useMemo( + () => + new Subject<{ + name: string; + value: string; + }>(), + [], + ); + + useEffect(() => { + const subscription = subject$ + .pipe(debounceTime(1000)) + .subscribe((props) => { + updateEnvironmentVariableAction({ + ...props, + mode: appState.mode, + }) + .then((result) => { + toast.success(result.message); + }) + .catch((err) => { + toast.error(`Failed to update ${props.name}: ${err.message}`); + }); + }); + + return () => { + return subscription.unsubscribe(); + }; + }, [subject$]); + + const onValueChanged = useCallback( + (props: { value: string; name: string }) => { + subject$.next({ + name: props.name, + value: props.value, + }); + }, + [subject$], + ); const renderVariable = (varState: EnvVariableState) => { - const isExpanded = expandedVars[varState.key] ?? false; - const isClientBundledValue = varState.key.startsWith('NEXT_PUBLIC_'); - const isValueVisible = showValues[varState.key] ?? isClientBundledValue; - const model = envVariables.find( (variable) => variable.name === varState.key, ); - // Enhanced validation logic to handle both regular and contextual validation - let validation: ValidationResult = { - success: true, - }; - - if (model) { - // First check if it's required but missing - if (model.required && !varState.effectiveValue) { - validation = { - success: false, - error: { - issues: [ - { - message: `This variable is required but missing from your environment files`, - }, - ], - }, - }; - } else if (model.contextualValidation) { - // Then check contextual validation - const dependenciesMet = model.contextualValidation.dependencies.some( - (dep) => { - const dependencyValue = allVariables[dep.variable] ?? ''; - - return dep.condition(dependencyValue, allVariables); - }, - ); - - if (dependenciesMet) { - // Only check for missing value or run validation if dependencies are met - if (!varState.effectiveValue) { - const dependencyErrors = model.contextualValidation.dependencies - .map((dep) => { - const dependencyValue = allVariables[dep.variable] ?? ''; - - const shouldValidate = dep.condition( - dependencyValue, - allVariables, - ); - - if (shouldValidate) { - const { success } = model.contextualValidation!.validate({ - value: varState.effectiveValue, - variables: allVariables, - mode: appState.mode, - }); - - if (success) { - return null; - } - - return dep.message; - } - - return null; - }) - .filter((message): message is string => message !== null); - - validation = { - success: dependencyErrors.length === 0, - error: { - issues: dependencyErrors.map((message) => ({ message })), - }, - }; - } else { - // If we have a value and dependencies are met, run contextual validation - const result = model.contextualValidation.validate({ - value: varState.effectiveValue, - variables: allVariables, - mode: appState.mode, - }); - - if (!result.success) { - validation = { - success: false, - error: { - issues: result.error.issues.map((issue) => ({ - message: issue.message, - })), - }, - }; - } - } - } - } else if (model.validate && varState.effectiveValue) { - // Only run regular validation if: - // 1. There's no contextual validation - // 2. There's a value to validate - const result = model.validate({ - value: varState.effectiveValue, - variables: allVariables, - mode: appState.mode, - }); - - if (!result.success) { - validation = { - success: false, - error: { - issues: result.error.issues.map((issue) => ({ - message: issue.message, - })), - }, - }; - } - } - } - - const canExpand = varState.definitions.length > 1 || !validation.success; + const isClientBundledValue = varState.key.startsWith('NEXT_PUBLIC_'); + const isValueVisible = showValues[varState.key] ?? !model?.secret; return ( -
-
+
+
-
+
- + {varState.key} @@ -311,11 +262,26 @@ function EnvList({ appState }: { appState: AppEnvState }) {
-
- {renderValue(varState.effectiveValue, isValueVisible)} -
+ + {renderValue(varState.effectiveValue, isValueVisible)} +
+ } + > + + - + + + +
-
- {canExpand && ( - - )} + +
@@ -441,7 +404,7 @@ function EnvList({ appState }: { appState: AppEnvState }) { - + Invalid Value @@ -461,90 +424,73 @@ function EnvList({ appState }: { appState: AppEnvState }) {
- {isExpanded && canExpand && ( -
- -
- - Errors - +
+ +
+ + + {varState.effectiveSource === 'MISSING' + ? `The variable ${varState.key} is required but missing` + : `The value for ${varState.key} is invalid`} + - - - {varState.effectiveSource === 'MISSING' - ? 'Missing Required Variable' - : 'Invalid Value'} - - - -
-
- {varState.effectiveSource === 'MISSING' - ? `The variable ${varState.key} is required but missing from your environment files:` - : `The value for ${varState.key} is invalid:`} -
- - {/* Enhanced error display */} -
- {validation.error?.issues.map((issue, index) => ( -
- • {issue.message} -
- ))} -
- - {/* Display dependency information if available */} - {model?.contextualValidation?.dependencies && ( -
-
Dependencies:
- - {model.contextualValidation.dependencies.map( - (dep, index) => ( -
- • Requires valid {dep.variable.toUpperCase()}{' '} - when {dep.message} -
- ), - )} + +
+
+ {varState.validation.error?.issues.map((issue, index) => ( +
+ • {issue}
- )} + ))}
- - -
- - 1}> -
- - Override Chain - + {/* Display dependency information if available */} + {model?.contextualValidation?.dependencies && ( +
+
Dependencies:
-
- {varState.definitions.map((def) => ( -
- - {def.source} - - -
- {renderValue(def.value, isValueVisible)} + {model.contextualValidation.dependencies.map( + (dep, index) => ( +
+ • Requires valid {dep.variable.toUpperCase()} when{' '} + {dep.message} +
+ ), + )}
-
- ))} -
+ )} +
+ + +
+
+ + 1}> +
+ + Override Chain + + +
+ {varState.definitions.map((def) => ( +
+ + {def.source} + +
+ ))}
- -
- )} +
+ +
); }; @@ -589,83 +535,20 @@ function EnvList({ appState }: { appState: AppEnvState }) { return varState.isOverridden; } - if (showInvalidVars) { - const allVariables = getEffectiveVariablesValue(appState); - - let hasError = false; - - if (model) { - if (model.contextualValidation) { - // Check for missing or invalid dependencies - const dependencyErrors = model.contextualValidation.dependencies - .map((dep) => { - const dependencyValue = allVariables[dep.variable] ?? ''; - - const shouldValidate = dep.condition( - dependencyValue, - allVariables, - ); - - if (shouldValidate) { - const { error } = model.contextualValidation!.validate({ - value: varState.effectiveValue, - variables: allVariables, - mode: appState.mode, - }); - - return error; - } - - return false; - }) - .filter(Boolean); - - if (dependencyErrors.length > 0) { - hasError = true; - } - } else if (model.validate) { - // Fall back to regular validation - const result = model.validate({ - value: varState.effectiveValue, - variables: allVariables, - mode: appState.mode, - }); - - hasError = !result.success; - } - } - - return hasError && isInSearch; + if (showInvalidVars && isInSearch) { + return !varState.validation.success; } return isInSearch; }; // Update groups to use allVarsWithValidation instead of appState.variables - const groups = Object.values(allVarsWithValidation) - .filter(filterVariable) - .reduce( - (acc, variable) => { - const group = acc.find((group) => group.category === variable.category); - - if (!group) { - acc.push({ - category: variable.category, - variables: [variable], - }); - } else { - group.variables.push(variable); - } - - return acc; - }, - [] as Array<{ category: string; variables: Array }>, - ); + const groups = getGroups(appState, filterVariable); return ( -
+
-
+
@@ -688,88 +571,68 @@ function EnvList({ appState }: { appState: AppEnvState }) { value={search} onChange={(e) => setSearch(e.target.value)} /> - - - - - - - - - Create a report from the environment variables. Useful for - creating support tickets. - - -
-
+
- {groups.map((group) => ( -
-
- - {group.category} - -
+
+
+
+ {groups.map((group) => { + const visibleVariables = group.variables.filter( + (item) => item.isVisible, + ); + + if (visibleVariables.length === 0) { + return null; + } -
- {group.variables.map((item) => { return ( - {renderVariable(item)} +
+
+ + {group.category} + +
+ +
+ {group.variables.map((item) => { + return ( + + {renderVariable(item)} + + ); + })} +
+
); })} -
-
- ))} - -
-
- No variables found + +
+
+ No variables found +
+
+
-
+ + +
); } -function createReportFromEnvState(state: AppEnvState) { - let report = ``; - - for (const key in state.variables) { - const variable = state.variables[key]; - - const variableReport = `${key}: ${JSON.stringify(variable, null, 2)}`; - ``; - - report += variableReport + '\n'; - } - - return report; -} - function FilterSwitcher(props: { filters: { secret: boolean; @@ -875,66 +738,15 @@ function FilterSwitcher(props: { function Summary({ appState }: { appState: AppEnvState }) { const varsArray = Object.values(appState.variables); - const allVariables = getEffectiveVariablesValue(appState); const overridden = varsArray.filter((variable) => variable.isOverridden); const handleFilterChange = useUpdateFilteredVariables(); // Find all variables with errors (including missing required and contextual validation) - const errors = envVariables.reduce((acc, model) => { - // Get the current value of this variable - const varState = appState.variables[model.name]; - const value = varState?.effectiveValue; - let hasError = false; + const variablesWithErrors = varsArray.filter((variable) => { + return !variable.validation.success; + }); - // Check if it's required but missing - if (model.required && !value) { - hasError = true; - } else if (model.contextualValidation) { - // Check if any dependency conditions are met - const dependenciesErrors = model.contextualValidation.dependencies.some( - (dep) => { - const dependencyValue = allVariables[dep.variable] ?? ''; - - const shouldValidate = dep.condition(dependencyValue, allVariables); - - if (shouldValidate) { - const { error } = model.contextualValidation!.validate({ - value: varState?.effectiveValue ?? '', - variables: allVariables, - mode: appState.mode, - }); - - return error; - } - }, - ); - - if (dependenciesErrors) { - hasError = true; - } - } else if (model.validate && value) { - // Only run regular validation if: - // 1. There's no contextual validation - // 2. There's a value to validate - const result = model.validate({ - value, - variables: allVariables, - mode: appState.mode, - }); - - if (!result.success) { - hasError = true; - } - } - - if (hasError) { - acc.push(model.name); - } - - return acc; - }, []); - - const validVariables = varsArray.length - errors.length; + const validVariables = varsArray.length - variablesWithErrors.length; return (
@@ -946,11 +758,11 @@ function Summary({ appState }: { appState: AppEnvState }) { 0, - 'text-green-500': errors.length === 0, + 'text-destructive': variablesWithErrors.length > 0, + 'text-green-500': variablesWithErrors.length === 0, })} > - {errors.length} Invalid + {variablesWithErrors.length} Invalid 0}> @@ -963,17 +775,59 @@ function Summary({ appState }: { appState: AppEnvState }) {
-
- 0}> +
+ 0}> + + + + + + + + + Copy environment variables to clipboard. You can place it in your + hosting provider to set up the full environment. + + +
); @@ -1027,3 +881,35 @@ function useUpdateFilteredVariables() { return useCallback(handleFilterChange, [router]); } + +async function copyToClipboard(text: string) { + try { + await navigator.clipboard.writeText(text); + } catch (err) { + console.error('Failed to copy:', err); + } +} + +function getGroups( + appState: AppEnvState, + filterVariable: (variable: EnvVariableState) => boolean, +) { + return Object.values(appState.variables).reduce( + (acc, variable) => { + const group = acc.find((group) => group.category === variable.category); + variable.isVisible = filterVariable(variable); + + if (!group) { + acc.push({ + category: variable.category, + variables: [variable], + }); + } else { + group.variables.push(variable); + } + + return acc; + }, + [] as Array<{ category: string; variables: Array }>, + ); +} diff --git a/apps/dev-tool/app/variables/components/dynamic-form-input.tsx b/apps/dev-tool/app/variables/components/dynamic-form-input.tsx new file mode 100644 index 000000000..5361dfca3 --- /dev/null +++ b/apps/dev-tool/app/variables/components/dynamic-form-input.tsx @@ -0,0 +1,161 @@ +import { useCallback } from 'react'; + +import { Input } from '@kit/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@kit/ui/select'; +import { Switch } from '@kit/ui/switch'; +import { Textarea } from '@kit/ui/textarea'; + +type ModelType = + | 'string' + | 'longString' + | 'number' + | 'boolean' + | 'enum' + | 'url' + | 'email'; + +interface DynamicFormInputProps { + type: ModelType; + value: string; + name: string; + onChange: (props: { name: string; value: string }) => void; + placeholder?: string; + enumValues?: Array; + className?: string; +} + +export function DynamicFormInput({ + type, + value, + name, + onChange, + placeholder, + enumValues = [], + className, +}: DynamicFormInputProps) { + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + onChange({ + name, + value: e.target.value, + }); + }, + [name, onChange], + ); + + const handleSwitchChange = useCallback( + (checked: boolean) => { + onChange({ + name, + value: checked ? 'true' : 'false', + }); + }, + [name, onChange], + ); + + const handleSelectChange = useCallback( + (value: string) => { + onChange({ + name, + value: value === '' ? 'none' : value, + }); + }, + [name, onChange], + ); + + switch (type) { + case 'longString': + return ( +