From 76bfeddd3223d05f123bfa21d160c4153d5ac858 Mon Sep 17 00:00:00 2001 From: Giancarlo Buomprisco Date: Tue, 29 Apr 2025 09:11:12 +0700 Subject: [PATCH] Dev Tools improvements (#247) * Refactor environment variables UI and update validation logic Enhanced the environment variables page layout for better responsiveness and structure by introducing new components and styles. Added `EnvListDisplay` for grouped variable display and adjusted several UI elements for clarity and consistency. Updated `NEXT_PUBLIC_SENTRY_ENVIRONMENT` validation to make it optional, aligning with updated requirements. * Add environment variable validation and enhance page headers Introduces robust validation for environment variables, ensuring correctness and contextual dependency checks. Updates page headers with titles and detailed descriptions for better usability and clarity. * Refactor variable page layout and improve code readability Rearranged className attributes in JSX for consistency and readability. Refactored map and enum validation logic for better formatting and maintainability. Applied minor corrections to types and formatting in other components. * Refactor styles and simplify component logic Updated badge variants to streamline styles and removed redundant hover states. Simplified logic in email page by extracting breadcrumb values and optimizing title rendering. Adjusted environment variables manager layout for cleaner rendering and removed unnecessary elements. * Add real-time translation updates with RxJS and UI improvements Introduced a Subject with debounce mechanism for handling translation updates, enhancing real-time editing in the translations comparison module. Improved UI components, including conditional rendering, better input handling, and layout adjustments. Implemented a server action for updating translations and streamlined type definitions in the emails page. * Enhance environment variable copying functionality and improve user feedback Updated the environment variables manager to copy structured environment variable data to the clipboard, improving usability. Adjusted toast notifications to provide clearer success and error messages during the copy process. Additionally, fixed a minor issue in the translations comparison component by ensuring proper filtering of keys based on the search input. * Add AI translation functionality and update dependencies Implemented a new action for translating missing strings using AI, enhancing the translations comparison component. Introduced a loading state during translation and improved error handling for translation updates. Updated package dependencies, including the addition of '@ai-sdk/openai' and 'ai' to facilitate AI-driven translations. Enhanced UI components for better user experience and streamlined translation management. --- apps/dev-tool/app/emails/[id]/page.tsx | 27 +- apps/dev-tool/app/emails/page.tsx | 6 +- apps/dev-tool/app/lib/connectivity-service.ts | 90 +- apps/dev-tool/app/page.tsx | 1 + .../components/translations-comparison.tsx | 269 ++++-- .../app/translations/lib/server-actions.ts | 161 ++++ apps/dev-tool/app/translations/page.tsx | 7 +- .../app-environment-variables-manager.tsx | 826 ++++++++---------- .../components/dynamic-form-input.tsx | 161 ++++ .../dev-tool/app/variables/lib/env-scanner.ts | 237 ++++- .../app/variables/lib/env-variables-model.ts | 152 +++- .../app/variables/lib/server-actions.ts | 84 ++ apps/dev-tool/app/variables/lib/types.ts | 7 + apps/dev-tool/app/variables/page.tsx | 45 +- apps/dev-tool/package.json | 5 +- apps/web/lib/i18n/i18n.settings.ts | 2 +- apps/web/package.json | 1 + apps/web/public/locales/en/common.json | 2 +- packages/ui/src/shadcn/badge.tsx | 19 +- pnpm-lock.yaml | 307 ++++++- 20 files changed, 1730 insertions(+), 679 deletions(-) create mode 100644 apps/dev-tool/app/translations/lib/server-actions.ts create mode 100644 apps/dev-tool/app/variables/components/dynamic-form-input.tsx create mode 100644 apps/dev-tool/app/variables/lib/server-actions.ts 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 ( +