mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
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:
parent
e041125426
commit
747519377b
2 changed files with 106 additions and 43 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue