feat(admin): streamline group management
This commit is contained in:
@@ -1,60 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
export function UserGroupSelect({
|
||||
action,
|
||||
effectiveGroupId,
|
||||
groups,
|
||||
search,
|
||||
returnTo,
|
||||
userId,
|
||||
userLabel,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
effectiveGroupId: string;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
search: string;
|
||||
returnTo: string;
|
||||
userId: string;
|
||||
userLabel: string;
|
||||
}) {
|
||||
const helpId = `group-help-${userId}`;
|
||||
return (
|
||||
<form action={action} className="flex items-center gap-2">
|
||||
<input name="userId" type="hidden" value={userId} />
|
||||
<input name="search" type="hidden" value={search} />
|
||||
<span className="sr-only" id={helpId}>Changing this selection applies the group immediately.</span>
|
||||
<GroupSelectControl effectiveGroupId={effectiveGroupId} groups={groups} helpId={helpId} userLabel={userLabel} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const helpId = useId();
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(effectiveGroupId);
|
||||
const [proposedGroupId, setProposedGroupId] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const currentGroup = groups.find((group) => group.id === effectiveGroupId);
|
||||
const proposedGroup = groups.find((group) => group.id === proposedGroupId);
|
||||
|
||||
function resetSelection() {
|
||||
setSelectedGroupId(effectiveGroupId);
|
||||
setProposedGroupId(null);
|
||||
}
|
||||
|
||||
function GroupSelectControl({
|
||||
effectiveGroupId,
|
||||
groups,
|
||||
helpId,
|
||||
userLabel,
|
||||
}: {
|
||||
effectiveGroupId: string;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
helpId: string;
|
||||
userLabel: string;
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<>
|
||||
<span className="sr-only" id={helpId}>Changing this selection opens a confirmation dialog.</span>
|
||||
<select
|
||||
aria-describedby={helpId}
|
||||
aria-label={`Group for ${userLabel}`}
|
||||
className="max-w-44 border border-line bg-canvas px-3 py-2 font-mono text-xs outline-none focus:border-accent disabled:cursor-wait disabled:opacity-60"
|
||||
defaultValue={effectiveGroupId}
|
||||
disabled={pending}
|
||||
name="groupId"
|
||||
onChange={(event) => event.currentTarget.form?.requestSubmit()}
|
||||
disabled={proposedGroupId !== null || submitting}
|
||||
onChange={(event) => {
|
||||
const nextGroupId = event.currentTarget.value;
|
||||
if (nextGroupId === effectiveGroupId) return;
|
||||
setSelectedGroupId(nextGroupId);
|
||||
setProposedGroupId(nextGroupId);
|
||||
dialogRef.current?.showModal();
|
||||
}}
|
||||
value={selectedGroupId}
|
||||
>
|
||||
{groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||
</select>
|
||||
<span aria-live="polite" className="sr-only">{pending ? "Updating group." : ""}</span>
|
||||
<button className="sr-only focus:not-sr-only" disabled={pending} type="submit">Apply group</button>
|
||||
<dialog
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
className="admin-modal m-auto w-[min(92vw,34rem)] border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||
onCancel={(event) => { if (submitting) event.preventDefault(); }}
|
||||
onClose={() => { if (!submitting) resetSelection(); }}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<form action={action} className="p-6 sm:p-8" onSubmit={() => setSubmitting(true)}>
|
||||
<input name="userId" type="hidden" value={userId} />
|
||||
<input name="groupId" type="hidden" value={proposedGroupId ?? effectiveGroupId} />
|
||||
<input name="returnTo" type="hidden" value={returnTo} />
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm membership</p>
|
||||
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>Move {userLabel}?</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>
|
||||
Change the effective group from <strong className="text-ink">{currentGroup?.name ?? "unknown"}</strong> to <strong className="text-ink">{proposedGroup?.name ?? "unknown"}</strong>. Their access policy changes immediately.
|
||||
</p>
|
||||
<AssignmentActions dialogRef={dialogRef} onPendingChange={setSubmitting} />
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentActions({ dialogRef, onPendingChange }: { dialogRef: RefObject<HTMLDialogElement | null>; onPendingChange: (pending: boolean) => void }) {
|
||||
const { pending } = useFormStatus();
|
||||
const observedPending = useRef(false);
|
||||
useEffect(() => {
|
||||
if (pending) {
|
||||
observedPending.current = true;
|
||||
onPendingChange(true);
|
||||
} else if (observedPending.current) {
|
||||
observedPending.current = false;
|
||||
onPendingChange(false);
|
||||
}
|
||||
}, [onPendingChange, pending]);
|
||||
return (
|
||||
<div className="mt-8 flex justify-end gap-3 border-t border-line pt-5">
|
||||
<button className="border border-line px-5 py-3 font-mono text-[10px] font-bold uppercase" disabled={pending} onClick={() => dialogRef.current?.close()} type="button">Cancel</button>
|
||||
<button className="bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase text-canvas disabled:cursor-wait disabled:opacity-60" disabled={pending} type="submit">{pending ? "Moving…" : "Confirm move"}</button>
|
||||
<span aria-live="polite" className="sr-only">{pending ? "Group change in progress." : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user