Files
myeasycms-v2/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx
Giancarlo Buomprisco 76bfeddd32 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.
2025-04-29 10:11:12 +08:00

916 lines
28 KiB
TypeScript

'use client';
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 {
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';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
import { cn } from '@kit/ui/utils';
import { AppEnvState, EnvVariableState } from '../lib/types';
import { DynamicFormInput } from './dynamic-form-input';
export function AppEnvironmentVariablesManager({
state,
}: React.PropsWithChildren<{
state: AppEnvState;
}>) {
return <EnvList appState={state} />;
}
function EnvListDisplay({
groups,
className,
hideSecret = false,
}: {
groups: Array<{
category: string;
variables: Array<EnvVariableState>;
}>;
className: string;
hideSecret?: boolean;
}) {
return (
<div className={cn(className)}>
<div
className={
'text-muted-foreground relative flex h-full flex-col rounded-lg font-mono text-xs'
}
>
<div className="bg-muted/50 sticky top-0 flex flex-col gap-y-1 rounded-lg p-4">
<div className={'sticky top-0 h-full overflow-auto pb-16 break-all'}>
{groups.map((group) => (
<div className="mb-4" key={group.category}>
<span># {group.category}</span>
{group.variables.map((variable) => {
const model = envVariables.find(
(item) => item.name === variable.key,
);
const isSecret = model?.secret;
const value =
isSecret && hideSecret
? '••••••••'
: variable.effectiveValue;
return (
<Link
href={`#var_${variable.key.toLowerCase()}`}
className={cn('block transition-all hover:underline', {
['text-orange-500']: variable.isOverridden,
['text-destructive']: !variable.validation.success,
['opacity-20']: !variable.isVisible,
})}
key={variable.key}
>
<span>{variable.key}</span>: {value}
</Link>
);
})}
</div>
))}
</div>
</div>
</div>
</div>
);
}
function EnvList({ appState }: { appState: AppEnvState }) {
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState('');
const searchParams = useSearchParams();
const showSecretVars = searchParams.get('secret') === 'true';
const showPublicVars = searchParams.get('public') === 'true';
const showPrivateVars = searchParams.get('private') === 'true';
const showOverriddenVars = searchParams.get('overridden') === 'true';
const showInvalidVars = searchParams.get('invalid') === 'true';
const toggleShowValue = (key: string) => {
setShowValues((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const renderValue = (value: string, isVisible: boolean) => {
if (!isVisible) {
if (!value) {
return `(empty)`;
}
return '••••••••';
}
return value;
};
const allVariables = getEffectiveVariablesValue(appState);
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 model = envVariables.find(
(variable) => variable.name === varState.key,
);
const isClientBundledValue = varState.key.startsWith('NEXT_PUBLIC_');
const isValueVisible = showValues[varState.key] ?? !model?.secret;
return (
<div
id={`var_${varState.key.toLowerCase()}`}
key={varState.key}
className={cn('animate-in fade-in py-6 transition-all', {
hidden: !varState.isVisible,
})}
>
<div className={'flex flex-col space-y-2'}>
<div className="flex items-start justify-between">
<div className="flex max-w-full flex-1 flex-col">
<div className="flex items-center gap-4">
<span
className={cn('font-mono text-sm font-semibold', {
'text-orange-500': varState.isOverridden,
'text-destructive': !varState.validation.success,
})}
>
{varState.key}
</span>
{model?.required && <Badge variant="outline">Required</Badge>}
{varState.effectiveSource === 'MISSING' && (
<Badge
variant={
// Show destructive if required OR if contextual validation dependencies are not met
model?.required ||
model?.contextualValidation?.dependencies.some((dep) => {
const dependencyValue =
allVariables[dep.variable] ?? '';
const shouldValidate = dep.condition(
dependencyValue,
allVariables,
);
if (!shouldValidate) {
return false;
}
return !model.contextualValidation!.validate({
value: varState.effectiveValue,
variables: allVariables,
mode: appState.mode,
}).success;
})
? 'destructive'
: 'outline'
}
>
Missing
</Badge>
)}
{varState.isOverridden && (
<Badge variant="warning">Overridden</Badge>
)}
</div>
<If condition={model}>
{(model) => (
<div className="flex items-center gap-2 py-1">
<span className="text-muted-foreground text-xs font-normal">
{model.description}
</span>
</div>
)}
</If>
<div className="mt-2 flex items-center gap-2">
<If
condition={isValueVisible || !varState.effectiveValue}
fallback={
<div className="max-w-auto bg-muted text-muted-foreground flex h-9 w-auto flex-1 items-center overflow-x-auto rounded border px-2 py-2 font-mono text-xs">
{renderValue(varState.effectiveValue, isValueVisible)}
</div>
}
>
<DynamicFormInput
type={model?.type ?? 'string'}
name={varState.key}
value={varState.effectiveValue}
onChange={onValueChanged}
placeholder={`Set a value for ${varState.key}`}
enumValues={model?.values}
className="text-xs"
/>
</If>
<If condition={model?.secret}>
<Button
variant="ghost"
size={'icon'}
onClick={() => toggleShowValue(varState.key)}
>
{isValueVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</Button>
</If>
<If condition={model && model.type !== 'boolean'}>
<Button
variant="ghost"
onClick={() => copyToClipboard(varState.effectiveValue)}
size={'icon'}
>
<Copy size={16} />
</Button>
</If>
</div>
<If condition={model?.hint}>
{(hint) => (
<div className="mt-2 flex items-center gap-2">
<span className="text-muted-foreground text-xs font-normal">
{hint}
</span>
</div>
)}
</If>
</div>
</div>
<div className="mt-2 flex gap-x-2">
<Badge
variant="outline"
className={cn({
'text-orange-500': !isClientBundledValue,
'text-green-500': isClientBundledValue,
})}
>
{isClientBundledValue ? `Public variable` : `Private variable`}
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="ml-2 h-3 w-3" />
</TooltipTrigger>
<TooltipContent>
{isClientBundledValue
? `This variable will be bundled into the client side. If this is a private variable, do not use "NEXT_PUBLIC".`
: `This variable is private and will not be bundled client side, so you cannot access it from React components rendered client side`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
<If condition={model?.secret}>
<Badge variant="outline" className={'text-destructive'}>
Secret Variable
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="ml-2 h-3 w-3" />
</TooltipTrigger>
<TooltipContent>
This is a secret key. Keep it safe!
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
</If>
<If condition={varState.effectiveSource !== 'MISSING'}>
<Badge
variant={'outline'}
className={cn({
'text-destructive':
varState.effectiveSource === '.env.production',
})}
>
{varState.effectiveSource}
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="ml-2 h-3 w-3" />
</TooltipTrigger>
<TooltipContent>
{varState.effectiveSource === '.env.local'
? `These variables are specific to this machine and are not committed`
: varState.effectiveSource === '.env.development'
? `These variables are only being used during development`
: varState.effectiveSource === '.env'
? `These variables are shared under all modes`
: `These variables are only used in production mode`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
</If>
<If condition={varState.isOverridden}>
<Badge variant="warning">
Overridden in {varState.effectiveSource}
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="ml-2 h-3 w-3" />
</TooltipTrigger>
<TooltipContent>
This variable was overridden by a variable in{' '}
{varState.effectiveSource}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
</If>
<If condition={!varState.validation.success}>
<Badge variant="destructive">
Invalid Value
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="ml-2 h-3 w-3" />
</TooltipTrigger>
<TooltipContent>
This variable has an invalid value. Drop down to view the
errors.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
</If>
</div>
</div>
<div className="flex w-full flex-col gap-y-2 py-4">
<If condition={!varState.validation.success}>
<div className={'flex flex-col space-y-2'}>
<Alert variant="destructive">
<AlertTitle>
{varState.effectiveSource === 'MISSING'
? `The variable ${varState.key} is required but missing`
: `The value for ${varState.key} is invalid`}
</AlertTitle>
<AlertDescription>
<div className="space-y-2">
<div className="space-y-1">
{varState.validation.error?.issues.map((issue, index) => (
<div key={index} className="text-sm">
{issue}
</div>
))}
</div>
{/* Display dependency information if available */}
{model?.contextualValidation?.dependencies && (
<div className="mt-4 space-y-1">
<div className="font-medium">Dependencies:</div>
{model.contextualValidation.dependencies.map(
(dep, index) => (
<div key={index} className="text-sm">
Requires valid {dep.variable.toUpperCase()} when{' '}
{dep.message}
</div>
),
)}
</div>
)}
</div>
</AlertDescription>
</Alert>
</div>
</If>
<If condition={varState.definitions.length > 1}>
<div className={'flex flex-col space-y-2'}>
<Heading level={6} className="text-sm font-medium">
Override Chain
</Heading>
<div className="w-full space-y-2">
{varState.definitions.map((def) => (
<div
key={`${def.key}-${def.source}`}
className="flex items-center gap-2"
>
<Badge
variant={'outline'}
className={cn({
'text-destructive': def.source === '.env.production',
})}
>
{def.source}
</Badge>
</div>
))}
</div>
</div>
</If>
</div>
</div>
);
};
const filterVariable = (varState: EnvVariableState) => {
const model = envVariables.find(
(variable) => variable.name === varState.key,
);
if (
!search &&
!showSecretVars &&
!showPublicVars &&
!showPrivateVars &&
!showInvalidVars &&
!showOverriddenVars
) {
return true;
}
const isSecret = model?.secret ?? false;
const isPublic = varState.key.startsWith('NEXT_PUBLIC_');
const isPrivate = !isPublic;
const isInSearch = search
? varState.key.toLowerCase().includes(search.toLowerCase())
: true;
if (showPublicVars && isInSearch) {
return isPublic;
}
if (showSecretVars && isInSearch) {
return isSecret;
}
if (showPrivateVars && isInSearch) {
return isPrivate;
}
if (showOverriddenVars && isInSearch) {
return varState.isOverridden;
}
if (showInvalidVars && isInSearch) {
return !varState.validation.success;
}
return isInSearch;
};
// Update groups to use allVarsWithValidation instead of appState.variables
const groups = getGroups(appState, filterVariable);
return (
<div className="flex h-full flex-1 flex-col gap-y-4">
<div className="flex items-center">
<div className="flex w-full space-x-2 py-0.5">
<div>
<EnvModeSelector mode={appState.mode} />
</div>
<div>
<FilterSwitcher
filters={{
secret: showSecretVars,
public: showPublicVars,
overridden: showOverriddenVars,
private: showPrivateVars,
invalid: showInvalidVars,
}}
/>
</div>
<Input
className={'w-full'}
placeholder="Search variables"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex flex-1 flex-col gap-y-4 overflow-hidden">
<Summary appState={appState} />
<div className="flex w-full flex-1 space-x-4 overflow-hidden">
<div className="flex w-6/12 flex-1 flex-col overflow-y-auto">
<div className="flex flex-col gap-y-4">
{groups.map((group) => {
const visibleVariables = group.variables.filter(
(item) => item.isVisible,
);
if (visibleVariables.length === 0) {
return null;
}
return (
<div
key={group.category}
className="flex flex-col rounded-lg border p-4"
>
<div>
<span className={'text-lg font-bold'}>
{group.category}
</span>
</div>
<div className="flex flex-col">
{group.variables.map((item) => {
return (
<Fragment key={item.key}>
{renderVariable(item)}
</Fragment>
);
})}
</div>
</div>
);
})}
<If condition={groups.length === 0}>
<div className="flex h-full flex-1 flex-col items-center justify-center gap-y-4 py-16">
<div className="text-muted-foreground text-sm">
No variables found
</div>
</div>
</If>
</div>
</div>
<EnvListDisplay
className="sticky top-0 w-6/12 overflow-y-auto"
groups={groups}
/>
</div>
</div>
</div>
);
}
function FilterSwitcher(props: {
filters: {
secret: boolean;
public: boolean;
overridden: boolean;
private: boolean;
invalid: boolean;
};
}) {
const secretVars = props.filters.secret;
const publicVars = props.filters.public;
const overriddenVars = props.filters.overridden;
const privateVars = props.filters.private;
const invalidVars = props.filters.invalid;
const handleFilterChange = useUpdateFilteredVariables();
const buttonLabel = () => {
const filters = [];
if (secretVars) filters.push('Secret');
if (publicVars) filters.push('Public');
if (overriddenVars) filters.push('Overridden');
if (privateVars) filters.push('Private');
if (invalidVars) filters.push('Invalid');
if (filters.length === 0) return 'Filter variables';
return filters.join(', ');
};
const allSelected =
!secretVars && !publicVars && !overriddenVars && !invalidVars;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="font-normal">
{buttonLabel()}
<ChevronsUpDownIcon className="text-muted-foreground ml-1 h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuCheckboxItem
checked={allSelected}
onCheckedChange={() => {
handleFilterChange('all', true);
}}
>
All
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={secretVars}
onCheckedChange={() => {
handleFilterChange('secret', !secretVars);
}}
>
Secret
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={privateVars}
onCheckedChange={() => {
handleFilterChange('private', !privateVars);
}}
>
Private
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={publicVars}
onCheckedChange={() => {
handleFilterChange('public', !publicVars);
}}
>
Public
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={invalidVars}
onCheckedChange={() => {
handleFilterChange('invalid', !invalidVars);
}}
>
Invalid
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={overriddenVars}
onCheckedChange={() => {
handleFilterChange('overridden', !overriddenVars);
}}
>
Overridden
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function Summary({ appState }: { appState: AppEnvState }) {
const varsArray = Object.values(appState.variables);
const overridden = varsArray.filter((variable) => variable.isOverridden);
const handleFilterChange = useUpdateFilteredVariables();
// Find all variables with errors (including missing required and contextual validation)
const variablesWithErrors = varsArray.filter((variable) => {
return !variable.validation.success;
});
const validVariables = varsArray.length - variablesWithErrors.length;
return (
<div className="flex justify-between space-x-4">
<div className="flex items-center gap-x-2">
<Badge variant={'outline'} className={'text-green-500'}>
{validVariables} Valid
</Badge>
<Badge
variant={'outline'}
className={cn({
'text-destructive': variablesWithErrors.length > 0,
'text-green-500': variablesWithErrors.length === 0,
})}
>
{variablesWithErrors.length} Invalid
</Badge>
<If condition={overridden.length > 0}>
<Badge
variant={'outline'}
className={cn({ 'text-orange-500': overridden.length > 0 })}
>
{overridden.length} Overridden
</Badge>
</If>
</div>
<div className={'flex items-center gap-x-2'}>
<If condition={variablesWithErrors.length > 0}>
<Button
size={'sm'}
variant={'outline'}
onClick={() => handleFilterChange('invalid', true, true)}
>
<EyeOffIcon className="mr-2 h-3 w-3" />
Display Invalid only
</Button>
</If>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size={'sm'}
onClick={() => {
let data = '';
const groups = getGroups(appState, () => true);
groups.forEach((group) => {
data += `# ${group.category}\n`;
group.variables.forEach((variable) => {
data += `${variable.key}=${variable.effectiveValue}\n`;
});
data += '\n';
});
const promise = copyToClipboard(data);
toast.promise(promise, {
loading: 'Copying environment variables...',
success: 'Environment variables copied to clipboard.',
error: 'Failed to copy environment variables to clipboard',
});
}}
>
<CopyIcon className={'mr-2 h-4 w-4'} />
<span>Copy env file to clipboard</span>
</Button>
</TooltipTrigger>
<TooltipContent>
Copy environment variables to clipboard. You can place it in your
hosting provider to set up the full environment.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
}
function getEffectiveVariablesValue(
appState: AppEnvState,
): Record<string, string> {
const varsArray = Object.values(appState.variables);
return varsArray.reduce(
(acc, variable) => ({
...acc,
[variable.key]: variable.effectiveValue,
}),
{},
);
}
function useUpdateFilteredVariables() {
const router = useRouter();
const handleFilterChange = (key: string, value: boolean, reset = false) => {
const searchParams = new URLSearchParams(window.location.search);
const path = window.location.pathname;
const resetAll = () => {
searchParams.delete('secret');
searchParams.delete('public');
searchParams.delete('overridden');
searchParams.delete('private');
searchParams.delete('invalid');
};
if (reset) {
resetAll();
}
if (key === 'all' && value) {
resetAll();
} else {
if (!value) {
searchParams.delete(key);
} else {
searchParams.set(key, 'true');
}
}
router.push(`${path}?${searchParams.toString()}`);
};
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<EnvVariableState> }>,
);
}