Skip to content

Commit 6bde5c3

Browse files
authored
🥂 feat: Toasts on All Save Actions (#75) (#81)
* feat: success/error toasts on every admin panel save action Wires click-ui's Toast component into every save mutation in the admin panel: config editor (per-field, bulk, YAML import), groups (create / edit / delete), roles (create / edit / delete), users (invite, delete, role / group assignment, user profile create / delete), system grants (EditCapabilitiesDialog), and per-field profile mutations (useProfileMutations). Uses click-ui's official createToast directly; ClickUIProvider already mounts the matching ToastProvider, so the global createToast routes through it. Toasts inherit click-ui brand colours, WCAG contrast, keyboard accessibility, swipe-to-dismiss, close button, ARIA live region, and 5s auto-dismiss from the library. src/utils/toast.ts is a five-line wrapper exposing notifySuccess and notifyError that delegate to click-ui's createToast. Call sites pass the affected resource through mutation variables (not closed-over component state) so the toast renders the correct name even when the confirm dialog has already cleared its target by the time the response lands. Refs AI-1206. * fix: throw instead of silently no-op when target missing in edit mutations EditGroupDialog, EditRoleDialog and EditCapabilitiesDialog mutationFns used to bail with an empty return when their target (group, role, principal) was missing, which React Query treats as success and which caused the new onSuccess handlers to fire a success toast and close the dialog without anything having been persisted. Throw a localised error in the unavailable case so onError fires instead, and add a matching guard at each call site so mutate is never invoked with a missing target in the first place. * fix: capture submitted name in mutation variables for create/edit toasts The five create and edit dialogs used to read the resource name from component state inside onSuccess instead of from the value that was submitted with the mutation. If the user edited the name field while the request was in flight (or the dialog reset before the toast fired), the toast could render an empty or wrong name even though the server saved the original value. Pass the submitted name through the mutation variables, use it for the actual API call, and read it back from the mutation's data or variables argument in onSuccess so the toast always reflects what was persisted.
1 parent a81b81c commit 6bde5c3

17 files changed

Lines changed: 283 additions & 259 deletions

src/components/access/CreateGroupDialog.tsx

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import type { AdminUserSearchResult } from '@librechat/data-schemas';
55
import type * as t from '@/types';
66
import { SelectedMemberList, UserSearchInline } from '@/components/shared';
77
import { addGroupMemberFn, createGroupFn } from '@/server';
8+
import { cn, notifySuccess, notifyError } from '@/utils';
89
import { useLocalize } from '@/hooks';
9-
import { cn } from '@/utils';
1010

1111
export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) {
1212
const localize = useLocalize();
@@ -27,20 +27,24 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) {
2727
};
2828

2929
const mutation = useMutation({
30-
mutationFn: async () => {
31-
const { group } = await createGroupFn({ data: { name, description } });
30+
mutationFn: async ({ name: submittedName }: { name: string }) => {
31+
const { group } = await createGroupFn({
32+
data: { name: submittedName, description },
33+
});
3234
for (const user of selectedUsers) {
3335
await addGroupMemberFn({ data: { groupId: group.id, userId: user.id } });
3436
}
37+
return { name: submittedName };
3538
},
36-
onSuccess: () => {
39+
onSuccess: (data) => {
3740
queryClient.invalidateQueries({ queryKey: ['groups'] });
3841
queryClient.invalidateQueries({ queryKey: ['groupMembers'] });
3942
queryClient.invalidateQueries({ queryKey: ['availableScopes'] });
4043
queryClient.invalidateQueries({ queryKey: ['groupAssignments'] });
44+
notifySuccess(localize('com_toast_group_created', { name: data.name }));
4145
resetAndClose();
4246
},
43-
onError: (err: Error) => setError(err.message),
47+
onError: (err: Error) => notifyError(err.message),
4448
});
4549

4650
const doSubmit = () => {
@@ -50,7 +54,7 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) {
5054
setActiveTab('details');
5155
return;
5256
}
53-
mutation.mutate();
57+
mutation.mutate({ name });
5458
};
5559

5660
const handleSubmit = (e: React.FormEvent) => {
@@ -89,14 +93,15 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) {
8993
ariaLabel={localize('com_access_create_group')}
9094
>
9195
<Tabs.TriggersList>
92-
<Tabs.Trigger value="details">
93-
{localize('com_access_tab_details')}
94-
</Tabs.Trigger>
95-
<Tabs.Trigger value="members">
96-
{localize('com_access_tab_members')}
97-
</Tabs.Trigger>
96+
<Tabs.Trigger value="details">{localize('com_access_tab_details')}</Tabs.Trigger>
97+
<Tabs.Trigger value="members">{localize('com_access_tab_members')}</Tabs.Trigger>
9898
</Tabs.TriggersList>
99-
<Tabs.Content value="details" forceMount tabIndex={-1} className={cn(activeTab !== 'details' && 'hidden')}>
99+
<Tabs.Content
100+
value="details"
101+
forceMount
102+
tabIndex={-1}
103+
className={cn(activeTab !== 'details' && 'hidden')}
104+
>
100105
<div className="flex flex-col gap-5 pt-5">
101106
<div className="flex flex-col gap-1.5">
102107
<label
@@ -133,7 +138,12 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) {
133138
</div>
134139
</div>
135140
</Tabs.Content>
136-
<Tabs.Content value="members" forceMount tabIndex={-1} className={cn(activeTab !== 'members' && 'hidden')}>
141+
<Tabs.Content
142+
value="members"
143+
forceMount
144+
tabIndex={-1}
145+
className={cn(activeTab !== 'members' && 'hidden')}
146+
>
137147
<div className="flex flex-col gap-4 pt-5">
138148
<UserSearchInline
139149
existingIds={selectedUsers.map((u) => u.id)}

src/components/access/CreateRoleDialog.tsx

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import type * as t from '@/types';
66
import { addRoleMemberFn, createRoleFn, updateRolePermissionsFn } from '@/server';
77
import { SelectedMemberList, UserSearchInline } from '@/components/shared';
88
import { RolePermissionsPanel } from './RolePermissionsPanel';
9+
import { cn, notifySuccess, notifyError } from '@/utils';
910
import { defaultPermissions } from '@/constants';
1011
import { useLocalize } from '@/hooks';
11-
import { cn } from '@/utils';
1212

1313
export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {
1414
const localize = useLocalize();
@@ -31,21 +31,23 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {
3131
};
3232

3333
const mutation = useMutation({
34-
mutationFn: async () => {
35-
const { role } = await createRoleFn({ data: { name, description } });
34+
mutationFn: async ({ name: submittedName }: { name: string }) => {
35+
const { role } = await createRoleFn({ data: { name: submittedName, description } });
3636
await updateRolePermissionsFn({ data: { id: role.id, permissions } });
3737
for (const user of selectedUsers) {
3838
await addRoleMemberFn({ data: { roleId: role.id, userId: user.id } });
3939
}
40+
return { name: submittedName };
4041
},
41-
onSuccess: () => {
42+
onSuccess: (data) => {
4243
queryClient.invalidateQueries({ queryKey: ['roles'] });
4344
queryClient.invalidateQueries({ queryKey: ['roleMembers'] });
4445
queryClient.invalidateQueries({ queryKey: ['availableScopes'] });
4546
queryClient.invalidateQueries({ queryKey: ['roleAssignments'] });
47+
notifySuccess(localize('com_toast_role_created', { name: data.name }));
4648
resetAndClose();
4749
},
48-
onError: (err: Error) => setError(err.message),
50+
onError: (err: Error) => notifyError(err.message),
4951
});
5052

5153
const doSubmit = () => {
@@ -55,7 +57,7 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {
5557
setActiveTab('details');
5658
return;
5759
}
58-
mutation.mutate();
60+
mutation.mutate({ name });
5961
};
6062

6163
const handleSubmit = (e: React.FormEvent) => {
@@ -94,17 +96,18 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {
9496
ariaLabel={localize('com_access_create_role')}
9597
>
9698
<Tabs.TriggersList>
97-
<Tabs.Trigger value="details">
98-
{localize('com_access_tab_details')}
99-
</Tabs.Trigger>
99+
<Tabs.Trigger value="details">{localize('com_access_tab_details')}</Tabs.Trigger>
100100
<Tabs.Trigger value="permissions">
101101
{localize('com_access_tab_permissions')}
102102
</Tabs.Trigger>
103-
<Tabs.Trigger value="members">
104-
{localize('com_access_tab_members')}
105-
</Tabs.Trigger>
103+
<Tabs.Trigger value="members">{localize('com_access_tab_members')}</Tabs.Trigger>
106104
</Tabs.TriggersList>
107-
<Tabs.Content value="details" forceMount tabIndex={-1} className={cn(activeTab !== 'details' && 'hidden')}>
105+
<Tabs.Content
106+
value="details"
107+
forceMount
108+
tabIndex={-1}
109+
className={cn(activeTab !== 'details' && 'hidden')}
110+
>
108111
<div className="flex flex-col gap-5 pt-5">
109112
<div className="flex flex-col gap-1.5">
110113
<label
@@ -141,7 +144,12 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {
141144
</div>
142145
</div>
143146
</Tabs.Content>
144-
<Tabs.Content value="permissions" forceMount tabIndex={-1} className={cn(activeTab !== 'permissions' && 'hidden')}>
147+
<Tabs.Content
148+
value="permissions"
149+
forceMount
150+
tabIndex={-1}
151+
className={cn(activeTab !== 'permissions' && 'hidden')}
152+
>
145153
<div className="pt-5">
146154
<RolePermissionsPanel
147155
permissions={permissions}
@@ -150,7 +158,12 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {
150158
/>
151159
</div>
152160
</Tabs.Content>
153-
<Tabs.Content value="members" forceMount tabIndex={-1} className={cn(activeTab !== 'members' && 'hidden')}>
161+
<Tabs.Content
162+
value="members"
163+
forceMount
164+
tabIndex={-1}
165+
className={cn(activeTab !== 'members' && 'hidden')}
166+
>
154167
<div className="flex flex-col gap-4 pt-5">
155168
<UserSearchInline
156169
existingIds={selectedUsers.map((u) => u.id)}

src/components/access/EditGroupDialog.tsx

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import {
1818
TrashButton,
1919
UserSearchInline,
2020
} from '@/components/shared';
21+
import { cn, notifySuccess, notifyError } from '@/utils';
2122
import { useLocalize } from '@/hooks';
22-
import { cn } from '@/utils';
2323

2424
type EditGroupTab = 'details' | 'members';
2525

@@ -73,10 +73,10 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
7373
};
7474

7575
const mutation = useMutation({
76-
mutationFn: async () => {
77-
if (!group) return;
76+
mutationFn: async ({ name: submittedName }: { name: string }) => {
77+
if (!group) throw new Error(localize('com_access_group_unavailable'));
7878
if (detailsDirty) {
79-
await updateGroupFn({ data: { id: group.id, name, description } });
79+
await updateGroupFn({ data: { id: group.id, name: submittedName, description } });
8080
}
8181
const memberResults = await Promise.allSettled([
8282
...pendingAdditions.map((user) =>
@@ -95,24 +95,27 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
9595
parts.push(localize('com_access_member_ops_failed', { count: failures.length }));
9696
throw new Error(parts.join(', '));
9797
}
98+
return { name: submittedName };
9899
},
99-
onSuccess: () => {
100+
onSuccess: (data) => {
100101
queryClient.invalidateQueries({ queryKey: ['groups'] });
101102
queryClient.invalidateQueries({ queryKey: ['groupAssignments'] });
102103
queryClient.invalidateQueries({ queryKey: ['groupMembers', group?.id] });
104+
notifySuccess(localize('com_toast_group_updated', { name: data.name }));
103105
onClose();
104106
},
105-
onError: (err: Error) => setError(err.message),
107+
onError: (err: Error) => notifyError(err.message),
106108
});
107109

108110
const doSubmit = () => {
109111
setError('');
112+
if (!group) return;
110113
if (!name.trim()) {
111114
setError(localize('com_access_name_required'));
112115
setActiveTab('details');
113116
return;
114117
}
115-
mutation.mutate();
118+
mutation.mutate({ name });
116119
};
117120

118121
const handleSubmit = (e: React.FormEvent) => {
@@ -140,14 +143,15 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
140143
ariaLabel={localize('com_access_edit_group')}
141144
>
142145
<Tabs.TriggersList>
143-
<Tabs.Trigger value="details">
144-
{localize('com_access_tab_details')}
145-
</Tabs.Trigger>
146-
<Tabs.Trigger value="members">
147-
{localize('com_access_tab_members')}
148-
</Tabs.Trigger>
146+
<Tabs.Trigger value="details">{localize('com_access_tab_details')}</Tabs.Trigger>
147+
<Tabs.Trigger value="members">{localize('com_access_tab_members')}</Tabs.Trigger>
149148
</Tabs.TriggersList>
150-
<Tabs.Content value="details" forceMount tabIndex={-1} className={cn(activeTab !== 'details' && 'hidden')}>
149+
<Tabs.Content
150+
value="details"
151+
forceMount
152+
tabIndex={-1}
153+
className={cn(activeTab !== 'details' && 'hidden')}
154+
>
151155
<div className="flex flex-col gap-5 pt-5">
152156
<div className="flex flex-col gap-1.5">
153157
<label
@@ -188,7 +192,12 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
188192
</div>
189193
</div>
190194
</Tabs.Content>
191-
<Tabs.Content value="members" forceMount tabIndex={-1} className={cn(activeTab !== 'members' && 'hidden')}>
195+
<Tabs.Content
196+
value="members"
197+
forceMount
198+
tabIndex={-1}
199+
className={cn(activeTab !== 'members' && 'hidden')}
200+
>
192201
<div className="flex flex-col gap-4 pt-5">
193202
{canManage && (
194203
<UserSearchInline
@@ -216,7 +225,12 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
216225
{localize('com_access_pending_removals', { count: pendingRemovals.length })}
217226
</span>
218227
<SelectedMemberList
219-
users={pendingRemovals.map((m) => ({ id: m.userId, name: m.name, email: m.email, avatarUrl: m.avatarUrl }))}
228+
users={pendingRemovals.map((m) => ({
229+
id: m.userId,
230+
name: m.name,
231+
email: m.email,
232+
avatarUrl: m.avatarUrl,
233+
}))}
220234
onRemove={unstageRemoval}
221235
disabled={mutation.isPending}
222236
/>
@@ -254,7 +268,9 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
254268
<Button
255269
type="primary"
256270
label={localize('com_ui_save')}
257-
disabled={!canManage || !name.trim() || (!detailsDirty && !membersDirty) || mutation.isPending}
271+
disabled={
272+
!canManage || !name.trim() || (!detailsDirty && !membersDirty) || mutation.isPending
273+
}
258274
/>
259275
</div>
260276
</form>

0 commit comments

Comments
 (0)