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:
+277
-190
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user