fix: add explicit Save button for application settings variables

https://sonarly.com/issue/25062?type=bug

Application configuration variables on the Settings page use an unreliable 250ms debounced auto-save pattern with no user feedback, causing users to lose input values when navigating away or when the component re-renders.

Fix: Replaced the unreliable 250ms debounced auto-save pattern with explicit per-variable Save buttons, matching the team's established pattern from `SettingsApplicationRegistrationGeneralTab`.

**Changes to `SettingsApplicationDetailEnvironmentVariablesTable.tsx`:**
- Removed `useDebouncedCallback` and the auto-save-on-keystroke behavior
- Changed prop from `onUpdate` (fire-and-forget) to `onSave` (async with Promise)
- Added per-variable Save button (IconCheck) that is disabled until the input has a non-empty value
- Input fields now use `placeholder` to show the current server value instead of pre-filling the input (prevents masked secret values from becoming editable content)
- Added `savingKeys` state to disable buttons during save operations
- On successful save, the edited value is cleared (input returns to placeholder state)

**Changes to `SettingsApplicationDetailSettingsTab.tsx`:**
- Added `useSnackBar` for success/error feedback on save
- Added `useApolloClient` to refetch `FindOneApplicationDocument` after mutation, keeping the Apollo cache in sync
- Changed from `onUpdate` to `onSave` prop, wrapping the mutation with try/catch for error handling
- Shows success toast "Variable {key} updated" on save, error toast on failure
This commit is contained in:
Sonarly Claude Code 2026-04-14 06:05:55 +00:00
parent e041125426
commit 747519377b
2 changed files with 106 additions and 43 deletions

View file

@ -1,12 +1,13 @@
import { type ApplicationVariable } from '~/generated-metadata/graphql';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { H2Title, IconCheck } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { TextInput } from '@/ui/input/components/TextInput';
import { useDebouncedCallback } from 'use-debounce';
import { useState } from 'react';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { isNonEmptyString } from '@sniptt/guards';
const StyledContainer = styled.div`
display: flex;
@ -14,49 +15,88 @@ const StyledContainer = styled.div`
gap: ${themeCssVariables.spacing[4]};
`;
const StyledVariableRow = styled.div`
align-items: flex-end;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledInputContainer = styled.div`
flex: 1;
`;
export const SettingsApplicationDetailEnvironmentVariablesTable = ({
envVariables,
onUpdate,
onSave,
}: {
envVariables: ApplicationVariable[];
onUpdate: (newEnv: Pick<ApplicationVariable, 'key' | 'value'>) => void;
readonly?: boolean;
onSave: (newEnv: Pick<ApplicationVariable, 'key' | 'value'>) => Promise<void>;
}) => {
const [editedEnvVariables, setEditedEnvVariables] = useState(envVariables);
const onUpdateDebounced = useDebouncedCallback(
(value: Pick<ApplicationVariable, 'key' | 'value'>) => {
onUpdate(value);
},
250,
);
const [editedValues, setEditedValues] = useState<Record<string, string>>({});
const [savingKeys, setSavingKeys] = useState<Record<string, boolean>>({});
const handleSave = async (envVariable: ApplicationVariable) => {
const newValue = editedValues[envVariable.key];
if (!isNonEmptyString(newValue)) {
return;
}
setSavingKeys((previous) => ({ ...previous, [envVariable.key]: true }));
try {
await onSave({ key: envVariable.key, value: newValue });
setEditedValues((previous) => {
const next = { ...previous };
delete next[envVariable.key];
return next;
});
} finally {
setSavingKeys((previous) => ({ ...previous, [envVariable.key]: false }));
}
};
const description =
editedEnvVariables.length > 0
envVariables.length > 0
? t`Set your application configuration variables`
: t`No variables to set for this application`;
return (
<Section>
<H2Title title={t`Configuration`} description={description} />
<StyledContainer>
{editedEnvVariables.map((editedEnvVariable) => (
<TextInput
key={editedEnvVariable.key}
label={editedEnvVariable.key}
value={editedEnvVariable.value}
onChange={(newValue) => {
setEditedEnvVariables((prevState) =>
prevState.map((val) => {
if (val.key === editedEnvVariable.key) {
return { ...val, value: newValue };
}
return val;
}),
);
onUpdateDebounced({ ...editedEnvVariable, value: newValue });
}}
placeholder={t`Value`}
fullWidth
/>
))}
{envVariables.map((envVariable) => {
const isDirty = isNonEmptyString(editedValues[envVariable.key]);
const isSaving = savingKeys[envVariable.key] ?? false;
return (
<StyledVariableRow key={envVariable.key}>
<StyledInputContainer>
<TextInput
label={envVariable.key}
value={editedValues[envVariable.key] ?? ''}
onChange={(newValue) => {
setEditedValues((previous) => ({
...previous,
[envVariable.key]: newValue,
}));
}}
placeholder={envVariable.value || t`Value`}
fullWidth
/>
</StyledInputContainer>
<Button
Icon={IconCheck}
variant="secondary"
size="small"
disabled={!isDirty || isSaving}
onClick={() => handleSave(envVariable)}
/>
</StyledVariableRow>
);
})}
</StyledContainer>
</Section>
);

View file

@ -1,6 +1,12 @@
import { type Application } from '~/generated-metadata/graphql';
import { t } from '@lingui/core/macro';
import {
type Application,
FindOneApplicationDocument,
} from '~/generated-metadata/graphql';
import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable';
import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useApolloClient } from '@apollo/client/react';
export const SettingsApplicationDetailSettingsTab = ({
application,
@ -11,6 +17,8 @@ export const SettingsApplicationDetailSettingsTab = ({
>;
}) => {
const { updateOneApplicationVariable } = useUpdateOneApplicationVariable();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const apolloClient = useApolloClient();
const envVariables = [...(application?.applicationVariables ?? [])].sort(
(a, b) => a.key.localeCompare(b.key),
@ -19,15 +27,30 @@ export const SettingsApplicationDetailSettingsTab = ({
return (
<SettingsApplicationDetailEnvironmentVariablesTable
envVariables={envVariables}
onUpdate={({ key, value }) =>
application?.id
? updateOneApplicationVariable({
key,
value,
applicationId: application.id,
})
: null
}
onSave={async ({ key, value }) => {
if (!application?.id) {
return;
}
try {
await updateOneApplicationVariable({
key,
value,
applicationId: application.id,
});
await apolloClient.refetchQueries({
include: [FindOneApplicationDocument],
});
enqueueSuccessSnackBar({
message: t`Variable ${key} updated`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error updating variable`,
});
throw new Error(t`Error updating variable`);
}
}}
/>
);
};