mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
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:
parent
cec2b92459
commit
77d1cb58ae
3 changed files with 65 additions and 7 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue