Improve admin modal design and accessibility with shared component

Refactors AdminFormDialog to create a reusable component for admin modals, including aria-labelledby/describedby attributes and Escape key closing functionality.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: fa532550-4873-409b-840a-ac4c474132f3
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-05-13 19:11:10 +00:00
parent a15365f459
commit 3af737186d
+277 -190
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { useState, useEffect, useRef, useMemo, useId, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { import {
@@ -361,6 +361,118 @@ function DeletionWarningDialog({
); );
} }
function AdminFormDialog({
title,
subtitle,
icon,
onCancel,
onSubmit,
submitLabel,
cancelLabel,
submitDisabled,
isPending,
children,
testId,
submitTestId,
maxWidth = "sm",
}: {
title: string;
subtitle?: string;
icon: ReactNode;
onCancel: () => void;
onSubmit: () => void;
submitLabel?: string;
cancelLabel?: string;
submitDisabled?: boolean;
isPending?: boolean;
children: ReactNode;
testId?: string;
submitTestId?: string;
maxWidth?: "sm" | "md" | "lg";
}) {
const { t } = useTranslation();
const titleId = useId();
const subtitleId = useId();
const widthClass =
maxWidth === "lg" ? "max-w-lg" : maxWidth === "md" ? "max-w-md" : "max-w-sm";
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isPending) {
e.stopPropagation();
onCancel();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isPending, onCancel]);
return (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div
className={cn(
"glass-panel rounded-3xl w-full overflow-hidden shadow-xl flex flex-col max-h-[90vh]",
widthClass,
)}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={subtitle ? subtitleId : undefined}
data-testid={testId}
>
<div className="flex items-start gap-3 p-5 sm:p-6">
<div
className="shrink-0 w-10 h-10 rounded-full bg-primary/10 text-primary flex items-center justify-center"
aria-hidden="true"
>
{icon}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<h2
id={titleId}
className="text-base sm:text-lg font-semibold text-foreground leading-snug"
>
{title}
</h2>
{subtitle && (
<p
id={subtitleId}
className="text-xs text-muted-foreground mt-0.5 leading-relaxed"
>
{subtitle}
</p>
)}
</div>
</div>
<div className="px-5 sm:px-6 pb-5 sm:pb-6 space-y-4 overflow-y-auto">
{children}
</div>
<div className="flex items-center justify-end gap-2 px-5 sm:px-6 py-4 border-t border-foreground/10 bg-foreground/[0.02]">
<Button
variant="ghost"
onClick={onCancel}
disabled={isPending}
className="text-muted-foreground hover:text-foreground"
>
{cancelLabel ?? t("common.cancel")}
</Button>
<Button
onClick={onSubmit}
disabled={isPending || submitDisabled}
data-testid={submitTestId}
className="min-w-[110px] gap-2"
>
{isPending && (
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
)}
{submitLabel ?? t("common.save")}
</Button>
</div>
</div>
</div>
);
}
// Local-state permission picker used by the "Add app" dialog. The full // Local-state permission picker used by the "Add app" dialog. The full
// AppPermissionsEditor below talks to /apps/:id/permissions, which can't // AppPermissionsEditor below talks to /apps/:id/permissions, which can't
// run before the app exists. This picker just collects ids into the form // run before the app exists. This picker just collects ids into the form
@@ -1754,41 +1866,37 @@ export default function AdminPage() {
{/* Create User Modal */} {/* Create User Modal */}
{newUserForm && ( {newUserForm && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"> <AdminFormDialog
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4"> title={t("admin.addUser")}
<h2 className="font-semibold text-foreground">{t("admin.addUser")}</h2> icon={<UserPlus size={20} />}
<div className="space-y-1"> onCancel={() => setNewUserForm(null)}
<Label>{t("admin.username")}</Label> onSubmit={handleCreateUser}
<Input isPending={createUser.isPending}
value={newUserForm.username} >
onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })} <div className="space-y-1.5">
className="bg-white/70 border-slate-200" <Label>{t("admin.username")}</Label>
/> <Input
</div> value={newUserForm.username}
<div className="space-y-1"> onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })}
<Label>{t("admin.email")}</Label> />
<Input
type="email"
value={newUserForm.email}
onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })}
className="bg-white/70 border-slate-200"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.password")}</Label>
<Input
type="password"
value={newUserForm.password}
onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })}
className="bg-white/70 border-slate-200"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => setNewUserForm(null)}>{t("common.cancel")}</Button>
<Button className="flex-1" onClick={handleCreateUser}>{t("common.save")}</Button>
</div>
</div> </div>
</div> <div className="space-y-1.5">
<Label>{t("admin.email")}</Label>
<Input
type="email"
value={newUserForm.email}
onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>{t("admin.password")}</Label>
<Input
type="password"
value={newUserForm.password}
onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })}
/>
</div>
</AdminFormDialog>
)} )}
<div className="flex-1 flex w-full"> <div className="flex-1 flex w-full">
@@ -4063,80 +4171,75 @@ function UsersPanel({
</div> </div>
{newUserOpen && ( {newUserOpen && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"> <AdminFormDialog
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3"> title={t("admin.addUser")}
<h2 className="font-semibold text-foreground">{t("admin.addUser")}</h2> icon={<UserPlus size={20} />}
<div className="space-y-1"> onCancel={() => setNewUserOpen(false)}
<Label>{t("auth.username")}</Label> isPending={createUser.isPending}
<Input value={newUserForm.username} onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })} className="bg-white/70 border-slate-200" /> onSubmit={() => {
</div> createUser.mutate(
<div className="space-y-1"> {
<Label>{t("auth.email")}</Label> data: {
<Input type="email" value={newUserForm.email} onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })} className="bg-white/70 border-slate-200" /> username: newUserForm.username,
</div> email: newUserForm.email,
<div className="space-y-1"> password: newUserForm.password,
<Label>{t("auth.password")}</Label> ...(newUserForm.groupIds.length > 0 ? { groupIds: newUserForm.groupIds } : {}),
<Input type="password" value={newUserForm.password} onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })} className="bg-white/70 border-slate-200" /> },
</div> },
<div className="space-y-1"> {
<Label>{t("admin.users.col.groups")}</Label> onSuccess: () => {
<div className="max-h-40 overflow-y-auto rounded-xl border border-slate-200 bg-white/70 p-2 space-y-1"> invalidateUsers();
{(groups ?? []).length === 0 && ( setNewUserOpen(false);
<div className="text-xs text-muted-foreground px-1 py-2">{t("admin.groups.empty")}</div> setNewUserForm({ username: "", email: "", password: "", groupIds: [] });
)} toast({ title: t("common.success") });
{(groups ?? []).map((g) => { },
const checked = newUserForm.groupIds.includes(g.id); onError: () => toast({ title: t("common.error"), variant: "destructive" }),
return ( },
<label key={g.id} className="flex items-center gap-2 text-sm cursor-pointer px-1 py-1 rounded hover:bg-slate-100/60"> );
<input }}
type="checkbox" >
checked={checked} <div className="space-y-1.5">
onChange={(e) => { <Label>{t("auth.username")}</Label>
setNewUserForm((prev) => ({ <Input value={newUserForm.username} onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })} />
...prev, </div>
groupIds: e.target.checked <div className="space-y-1.5">
? [...prev.groupIds, g.id] <Label>{t("auth.email")}</Label>
: prev.groupIds.filter((id) => id !== g.id), <Input type="email" value={newUserForm.email} onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })} />
})); </div>
}} <div className="space-y-1.5">
/> <Label>{t("auth.password")}</Label>
<span>{g.name}</span> <Input type="password" value={newUserForm.password} onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })} />
</label> </div>
); <div className="space-y-1.5">
})} <Label>{t("admin.users.col.groups")}</Label>
</div> <div className="max-h-44 overflow-y-auto rounded-xl border border-foreground/10 bg-foreground/[0.03] p-1.5 space-y-0.5">
</div> {(groups ?? []).length === 0 && (
<div className="flex gap-2 pt-2"> <div className="text-xs text-muted-foreground px-2 py-2">{t("admin.groups.empty")}</div>
<Button variant="outline" className="flex-1" onClick={() => setNewUserOpen(false)}>{t("common.cancel")}</Button> )}
<Button {(groups ?? []).map((g) => {
className="flex-1" const checked = newUserForm.groupIds.includes(g.id);
onClick={() => { return (
createUser.mutate( <label key={g.id} className="flex items-center gap-2 text-sm cursor-pointer px-2 py-1.5 rounded-lg hover:bg-foreground/5 transition-colors">
{ <input
data: { type="checkbox"
username: newUserForm.username, className="accent-primary"
email: newUserForm.email, checked={checked}
password: newUserForm.password, onChange={(e) => {
...(newUserForm.groupIds.length > 0 ? { groupIds: newUserForm.groupIds } : {}), setNewUserForm((prev) => ({
}, ...prev,
}, groupIds: e.target.checked
{ ? [...prev.groupIds, g.id]
onSuccess: () => { : prev.groupIds.filter((id) => id !== g.id),
invalidateUsers(); }));
setNewUserOpen(false); }}
setNewUserForm({ username: "", email: "", password: "", groupIds: [] }); />
toast({ title: t("common.success") }); <span className="text-foreground">{g.name}</span>
}, </label>
onError: () => toast({ title: t("common.error"), variant: "destructive" }), );
}, })}
);
}}
>
{t("common.save")}
</Button>
</div> </div>
</div> </div>
</div> </AdminFormDialog>
)} )}
{editingUser && ( {editingUser && (
@@ -4709,42 +4812,36 @@ function GroupsPanel({
</div> </div>
{createOpen && ( {createOpen && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"> <AdminFormDialog
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3"> title={t("admin.groups.addGroup")}
<h2 className="font-semibold text-foreground">{t("admin.groups.addGroup")}</h2> icon={<UsersIcon size={20} />}
<div className="space-y-1"> onCancel={() => setCreateOpen(false)}
<Label>{t("admin.groups.name")}</Label> submitDisabled={!createForm.name.trim()}
<Input value={createForm.name} onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })} className="bg-white/70 border-slate-200" data-testid="group-name-input" /> isPending={createGroup.isPending}
</div> submitTestId="create-group-submit"
<div className="space-y-1"> onSubmit={() =>
<Label>{t("admin.groups.descriptionAr")}</Label> createGroup.mutate(
<Input value={createForm.descriptionAr} onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })} className="bg-white/70 border-slate-200" dir="rtl" /> { data: { name: createForm.name.trim(), descriptionAr: createForm.descriptionAr || null, descriptionEn: createForm.descriptionEn || null } },
</div> {
<div className="space-y-1"> onSuccess: () => { invalidate(); setCreateOpen(false); toast({ title: t("common.success") }); },
<Label>{t("admin.groups.descriptionEn")}</Label> onError: () => toast({ title: t("common.error"), variant: "destructive" }),
<Input value={createForm.descriptionEn} onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })} className="bg-white/70 border-slate-200" dir="ltr" /> },
</div> )
<div className="flex gap-2 pt-2"> }
<Button variant="outline" className="flex-1" onClick={() => setCreateOpen(false)}>{t("common.cancel")}</Button> >
<Button <div className="space-y-1.5">
className="flex-1" <Label>{t("admin.groups.name")}</Label>
disabled={!createForm.name.trim()} <Input value={createForm.name} onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })} data-testid="group-name-input" />
onClick={() =>
createGroup.mutate(
{ data: { name: createForm.name.trim(), descriptionAr: createForm.descriptionAr || null, descriptionEn: createForm.descriptionEn || null } },
{
onSuccess: () => { invalidate(); setCreateOpen(false); toast({ title: t("common.success") }); },
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
)
}
data-testid="create-group-submit"
>
{t("common.save")}
</Button>
</div>
</div> </div>
</div> <div className="space-y-1.5">
<Label>{t("admin.groups.descriptionAr")}</Label>
<Input value={createForm.descriptionAr} onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })} dir="rtl" />
</div>
<div className="space-y-1.5">
<Label>{t("admin.groups.descriptionEn")}</Label>
<Input value={createForm.descriptionEn} onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })} dir="ltr" />
</div>
</AdminFormDialog>
)} )}
{editingGroupId != null && ( {editingGroupId != null && (
@@ -6496,55 +6593,45 @@ function RolesPanel({
</div> </div>
{createOpen && ( {createOpen && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"> <AdminFormDialog
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3" data-testid="create-role-dialog"> title={t("admin.roles.addRole")}
<h2 className="font-semibold text-foreground">{t("admin.roles.addRole")}</h2> icon={<KeyRound size={20} />}
<div className="space-y-1"> onCancel={() => setCreateOpen(false)}
<Label>{t("admin.roles.name")}</Label> onSubmit={handleCreate}
<Input submitDisabled={!createForm.name.trim()}
value={createForm.name} isPending={createRole.isPending}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })} testId="create-role-dialog"
className="bg-white/70 border-slate-200" submitTestId="create-role-submit"
placeholder={t("admin.roles.namePlaceholder")} >
data-testid="role-name-input" <div className="space-y-1.5">
/> <Label>{t("admin.roles.name")}</Label>
<p className="text-xs text-muted-foreground">{t("admin.roles.nameHint")}</p> <Input
</div> value={createForm.name}
<div className="space-y-1"> onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
<Label>{t("admin.roles.descriptionAr")}</Label> placeholder={t("admin.roles.namePlaceholder")}
<Input data-testid="role-name-input"
value={createForm.descriptionAr} />
onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })} <p className="text-xs text-muted-foreground">{t("admin.roles.nameHint")}</p>
className="bg-white/70 border-slate-200"
dir="rtl"
data-testid="role-desc-ar-input"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.roles.descriptionEn")}</Label>
<Input
value={createForm.descriptionEn}
onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
data-testid="role-desc-en-input"
/>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setCreateOpen(false)}>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
disabled={!createForm.name.trim() || createRole.isPending}
onClick={handleCreate}
data-testid="create-role-submit"
>
{createRole.isPending ? <Loader2 size={14} className="animate-spin" /> : t("common.save")}
</Button>
</div>
</div> </div>
</div> <div className="space-y-1.5">
<Label>{t("admin.roles.descriptionAr")}</Label>
<Input
value={createForm.descriptionAr}
onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })}
dir="rtl"
data-testid="role-desc-ar-input"
/>
</div>
<div className="space-y-1.5">
<Label>{t("admin.roles.descriptionEn")}</Label>
<Input
value={createForm.descriptionEn}
onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })}
dir="ltr"
data-testid="role-desc-en-input"
/>
</div>
</AdminFormDialog>
)} )}
{editingId != null && ( {editingId != null && (