feat: show a saving spinner and allow cancelling credential edits

Drive the tool credential Save button from the real mutation state so it
shows a spinner while the request is in flight, and add a Cancel button
when re-editing already-saved credentials so the edit can be dismissed.
This commit is contained in:
Marco Beretta 2026-07-02 03:49:07 +02:00
parent cec2b92459
commit 77d1cb58ae
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 65 additions and 7 deletions

View file

@ -1,5 +1,13 @@
import { useForm, useWatch } from 'react-hook-form';
import { HoverCard, HoverCardTrigger, SecretInput, Input, Label, Button } from '@librechat/client';
import {
Input,
Label,
Button,
Spinner,
HoverCard,
SecretInput,
HoverCardTrigger,
} from '@librechat/client';
import type { TPlugin, TPluginAuthConfig, TPluginAction } from 'librechat-data-provider';
import type { RegisterOptions } from 'react-hook-form';
import PluginTooltip from './PluginTooltip';
@ -70,9 +78,19 @@ type TPluginAuthFormProps = {
plugin: TPlugin | undefined;
onSubmit: (installActionData: TPluginAction) => void;
isEntityTool?: boolean;
/** External in-flight state (e.g. a mutation) so the button reflects the real save. */
isSaving?: boolean;
/** When provided, renders a Cancel button (used when re-editing saved credentials). */
onCancel?: () => void;
};
function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps) {
function PluginAuthForm({
plugin,
onSubmit,
isEntityTool,
isSaving,
onCancel,
}: TPluginAuthFormProps) {
const {
register,
control,
@ -84,6 +102,7 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
const watchedValues = useWatch({ control });
const authConfig = plugin?.authConfig ?? [];
const allFieldsOptional = authConfig.length > 0 && authConfig.every((c) => c.optional === true);
const saving = isSubmitting || isSaving === true;
const submit = handleSubmit((auth) =>
onSubmit({
@ -178,14 +197,20 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
);
})}
</div>
<div className="mt-4 flex justify-end">
<div className="mt-4 flex justify-end gap-2">
{onCancel && (
<Button type="button" variant="outline" disabled={saving} onClick={onCancel}>
{localize('com_ui_cancel')}
</Button>
)}
<Button
type="button"
variant="submit"
disabled={allFieldsOptional ? isSubmitting : !isDirty || !isValid || isSubmitting}
disabled={allFieldsOptional ? saving : !isDirty || !isValid || saving}
onClick={submit}
>
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
{saving && <Spinner className="size-4" aria-hidden="true" />}
{saving ? localize('com_ui_saving') : localize('com_ui_save')}
</Button>
</div>
</form>

View file

@ -1,5 +1,5 @@
import { render, screen } from 'test/layout-test-utils';
import userEvent from '@testing-library/user-event';
import { render, screen } from 'test/layout-test-utils';
import PluginAuthForm from '../PluginAuthForm';
describe('PluginAuthForm', () => {
@ -76,4 +76,29 @@ describe('PluginAuthForm', () => {
},
});
});
it('reflects an external saving state as a disabled, in-progress submit button', () => {
//@ts-ignore - dont need all props of plugin
render(<PluginAuthForm plugin={plugin} onSubmit={onSubmit} isSaving />);
const button = screen.getByRole('button', { name: 'Saving...' });
expect(button).toBeDisabled();
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
});
it('renders a Cancel button when onCancel is provided and invokes it on click', async () => {
const onCancel = jest.fn();
//@ts-ignore - dont need all props of plugin
render(<PluginAuthForm plugin={plugin} onSubmit={onSubmit} onCancel={onCancel} />);
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onSubmit).not.toHaveBeenCalled();
});
it('does not render a Cancel button by default', () => {
//@ts-ignore - dont need all props of plugin
render(<PluginAuthForm plugin={plugin} onSubmit={onSubmit} />);
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
});
});

View file

@ -83,7 +83,15 @@ export default function ToolSection({ item }: Props) {
</button>
</div>
)}
{showForm && <PluginAuthForm plugin={item.plugin} isEntityTool onSubmit={handleSubmit} />}
{showForm && (
<PluginAuthForm
plugin={item.plugin}
isEntityTool
isSaving={updateUserPlugins.isLoading}
onCancel={editing ? () => setEditing(false) : undefined}
onSubmit={handleSubmit}
/>
)}
</div>
);
}