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.
This commit is contained in:
Riyadh
2026-05-13 19:11:10 +00:00
parent 5b1955fe34
commit 68a338f4cf
+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 { useLocation } from "wouter";
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
// AppPermissionsEditor below talks to /apps/:id/permissions, which can't
// run before the app exists. This picker just collects ids into the form
@@ -1754,41 +1866,37 @@ export default function AdminPage() {
{/* Create User Modal */}
{newUserForm && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4">
<h2 className="font-semibold text-foreground">{t("admin.addUser")}</h2>
<div className="space-y-1">
<Label>{t("admin.username")}</Label>
<Input
value={newUserForm.username}
onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })}
className="bg-white/70 border-slate-200"
/>
</div>
<div className="space-y-1">
<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>
<AdminFormDialog
title={t("admin.addUser")}
icon={<UserPlus size={20} />}
onCancel={() => setNewUserForm(null)}
onSubmit={handleCreateUser}
isPending={createUser.isPending}
>
<div className="space-y-1.5">
<Label>{t("admin.username")}</Label>
<Input
value={newUserForm.username}
onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })}
/>
</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">
@@ -4063,80 +4171,75 @@ function UsersPanel({
</div>
{newUserOpen && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3">
<h2 className="font-semibold text-foreground">{t("admin.addUser")}</h2>
<div className="space-y-1">
<Label>{t("auth.username")}</Label>
<Input value={newUserForm.username} onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })} className="bg-white/70 border-slate-200" />
</div>
<div className="space-y-1">
<Label>{t("auth.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("auth.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="space-y-1">
<Label>{t("admin.users.col.groups")}</Label>
<div className="max-h-40 overflow-y-auto rounded-xl border border-slate-200 bg-white/70 p-2 space-y-1">
{(groups ?? []).length === 0 && (
<div className="text-xs text-muted-foreground px-1 py-2">{t("admin.groups.empty")}</div>
)}
{(groups ?? []).map((g) => {
const checked = newUserForm.groupIds.includes(g.id);
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}
onChange={(e) => {
setNewUserForm((prev) => ({
...prev,
groupIds: e.target.checked
? [...prev.groupIds, g.id]
: prev.groupIds.filter((id) => id !== g.id),
}));
}}
/>
<span>{g.name}</span>
</label>
);
})}
</div>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setNewUserOpen(false)}>{t("common.cancel")}</Button>
<Button
className="flex-1"
onClick={() => {
createUser.mutate(
{
data: {
username: newUserForm.username,
email: newUserForm.email,
password: newUserForm.password,
...(newUserForm.groupIds.length > 0 ? { groupIds: newUserForm.groupIds } : {}),
},
},
{
onSuccess: () => {
invalidateUsers();
setNewUserOpen(false);
setNewUserForm({ username: "", email: "", password: "", groupIds: [] });
toast({ title: t("common.success") });
},
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
);
}}
>
{t("common.save")}
</Button>
<AdminFormDialog
title={t("admin.addUser")}
icon={<UserPlus size={20} />}
onCancel={() => setNewUserOpen(false)}
isPending={createUser.isPending}
onSubmit={() => {
createUser.mutate(
{
data: {
username: newUserForm.username,
email: newUserForm.email,
password: newUserForm.password,
...(newUserForm.groupIds.length > 0 ? { groupIds: newUserForm.groupIds } : {}),
},
},
{
onSuccess: () => {
invalidateUsers();
setNewUserOpen(false);
setNewUserForm({ username: "", email: "", password: "", groupIds: [] });
toast({ title: t("common.success") });
},
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
);
}}
>
<div className="space-y-1.5">
<Label>{t("auth.username")}</Label>
<Input value={newUserForm.username} onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>{t("auth.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("auth.password")}</Label>
<Input type="password" value={newUserForm.password} onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>{t("admin.users.col.groups")}</Label>
<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">
{(groups ?? []).length === 0 && (
<div className="text-xs text-muted-foreground px-2 py-2">{t("admin.groups.empty")}</div>
)}
{(groups ?? []).map((g) => {
const checked = newUserForm.groupIds.includes(g.id);
return (
<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
type="checkbox"
className="accent-primary"
checked={checked}
onChange={(e) => {
setNewUserForm((prev) => ({
...prev,
groupIds: e.target.checked
? [...prev.groupIds, g.id]
: prev.groupIds.filter((id) => id !== g.id),
}));
}}
/>
<span className="text-foreground">{g.name}</span>
</label>
);
})}
</div>
</div>
</div>
</AdminFormDialog>
)}
{editingUser && (
@@ -4709,42 +4812,36 @@ function GroupsPanel({
</div>
{createOpen && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3">
<h2 className="font-semibold text-foreground">{t("admin.groups.addGroup")}</h2>
<div className="space-y-1">
<Label>{t("admin.groups.name")}</Label>
<Input value={createForm.name} onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })} className="bg-white/70 border-slate-200" data-testid="group-name-input" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionAr")}</Label>
<Input value={createForm.descriptionAr} onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })} className="bg-white/70 border-slate-200" dir="rtl" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionEn")}</Label>
<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
className="flex-1"
disabled={!createForm.name.trim()}
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>
<AdminFormDialog
title={t("admin.groups.addGroup")}
icon={<UsersIcon size={20} />}
onCancel={() => setCreateOpen(false)}
submitDisabled={!createForm.name.trim()}
isPending={createGroup.isPending}
submitTestId="create-group-submit"
onSubmit={() =>
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" }),
},
)
}
>
<div className="space-y-1.5">
<Label>{t("admin.groups.name")}</Label>
<Input value={createForm.name} onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })} data-testid="group-name-input" />
</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 && (
@@ -6496,55 +6593,45 @@ function RolesPanel({
</div>
{createOpen && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3" data-testid="create-role-dialog">
<h2 className="font-semibold text-foreground">{t("admin.roles.addRole")}</h2>
<div className="space-y-1">
<Label>{t("admin.roles.name")}</Label>
<Input
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
className="bg-white/70 border-slate-200"
placeholder={t("admin.roles.namePlaceholder")}
data-testid="role-name-input"
/>
<p className="text-xs text-muted-foreground">{t("admin.roles.nameHint")}</p>
</div>
<div className="space-y-1">
<Label>{t("admin.roles.descriptionAr")}</Label>
<Input
value={createForm.descriptionAr}
onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })}
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>
<AdminFormDialog
title={t("admin.roles.addRole")}
icon={<KeyRound size={20} />}
onCancel={() => setCreateOpen(false)}
onSubmit={handleCreate}
submitDisabled={!createForm.name.trim()}
isPending={createRole.isPending}
testId="create-role-dialog"
submitTestId="create-role-submit"
>
<div className="space-y-1.5">
<Label>{t("admin.roles.name")}</Label>
<Input
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
placeholder={t("admin.roles.namePlaceholder")}
data-testid="role-name-input"
/>
<p className="text-xs text-muted-foreground">{t("admin.roles.nameHint")}</p>
</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 && (