review: gate Danger Zone on members fetched + reset typed input on rename

Addresses self-review findings on #1238:

- Previously the Danger Zone rendered immediately with `members = []`, so
  the Delete workspace block (gated on `isOwner`, which is derived from
  an empty members list) would flash in once the query settled. Gate the
  whole section on `membersFetched` so it appears once with correct
  controls.
- Reset `typed` on `workspaceName` change too — if another owner renames
  the workspace while the dialog is open, the already-typed string stops
  matching silently; resetting surfaces the mismatch.
- Added two tests: unicode/special-char names match literally; rename
  mid-dialog clears the input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-17 11:05:51 +08:00
parent 133080197b
commit e0316fda81
3 changed files with 54 additions and 4 deletions

View File

@@ -126,6 +126,48 @@ describe("DeleteWorkspaceDialog", () => {
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
});
it("matches names with spaces, unicode, and other non-ASCII characters literally", async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
render(
<DeleteWorkspaceDialog
workspaceName="My 团队 🚀"
open
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
);
const input = screen.getByRole("textbox");
await user.type(input, "My 团队 🚀");
expect(screen.getByRole("button", { name: "Delete workspace" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Delete workspace" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("resets the input when the workspace being deleted changes (e.g. rename mid-dialog)", () => {
const { rerender } = render(
<DeleteWorkspaceDialog
workspaceName="old-name"
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
);
const input = screen.getByRole("textbox") as HTMLInputElement;
// Simulate user typing (set value directly since userEvent.type would
// lose focus across re-renders).
input.value = "old-name";
rerender(
<DeleteWorkspaceDialog
workspaceName="new-name"
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
);
expect(screen.getByRole("textbox")).toHaveValue("");
});
it("clears the input when reopened so prior attempts don't leak", async () => {
const user = userEvent.setup();
const { rerender } = render(

View File

@@ -45,9 +45,13 @@ export function DeleteWorkspaceDialog({
const [typed, setTyped] = useState("");
const matched = typed === workspaceName;
// Reset on close (so reopening for a different workspace doesn't leak
// the prior attempt) AND on workspaceName change (if another owner
// renames the workspace while the dialog is open, the already-typed
// string stops matching and there'd be no feedback explaining why).
useEffect(() => {
if (!open) setTyped("");
}, [open]);
setTyped("");
}, [open, workspaceName]);
const submit = () => {
if (!matched || loading) return;

View File

@@ -38,7 +38,7 @@ export function WorkspaceTab() {
const user = useAuthStore((s) => s.user);
const workspace = useCurrentWorkspace();
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: members = [], isFetched: membersFetched } = useQuery(memberListOptions(wsId));
const qc = useQueryClient();
const leaveWorkspace = useLeaveWorkspace();
const deleteWorkspace = useDeleteWorkspace();
@@ -214,7 +214,10 @@ export function WorkspaceTab() {
</Card>
</section>
{/* Danger Zone */}
{/* Danger Zone — gated on the member query settling so the owner-only
Delete button and the sole-owner Leave guidance don't flash in
after mount. */}
{membersFetched && (
<section className="space-y-4">
<div className="flex items-center gap-2">
<LogOut className="h-4 w-4 text-muted-foreground" />
@@ -265,6 +268,7 @@ export function WorkspaceTab() {
</CardContent>
</Card>
</section>
)}
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>
<AlertDialogContent>