mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
fix(dashboard): use native form submission in create-project dialog
Extract project creation into a shared handler and wrap the dialog in a form so Enter submits and keyboard users can create projects normally. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e95ac24603
commit
6ffe8a1363
@ -27,7 +27,7 @@ import { PlusCircleIcon } from "@phosphor-icons/react";
|
||||
import { type AdminOwnedProject } from "@hexclave/next";
|
||||
import { runAsynchronouslyWithAlert, wait } from "@hexclave/shared/dist/utils/promises";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type FormEvent, type KeyboardEvent, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ProjectOnboardingWizard } from "./project-onboarding-wizard";
|
||||
import {
|
||||
@ -113,6 +113,87 @@ function PageClientInner() {
|
||||
router.replace(query.length > 0 ? `/new-project?${query}` : "/new-project");
|
||||
}, [router, searchParams]);
|
||||
|
||||
const createProject = useCallback(async () => {
|
||||
if (!beginPendingAction(creatingProjectRef, setCreatingProject)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const trimmedProjectName = projectName.trim();
|
||||
if (trimmedProjectName.length === 0) {
|
||||
throw new Error("Project name is required.");
|
||||
}
|
||||
|
||||
const firstTeam = teams.at(0);
|
||||
const teamId = selectedTeamId ?? user.selectedTeam?.id ?? firstTeam?.id;
|
||||
if (teamId === undefined) {
|
||||
throw new Error("Select a team before creating the project.");
|
||||
}
|
||||
|
||||
const newProject = await user.createProject({
|
||||
displayName: trimmedProjectName,
|
||||
teamId,
|
||||
onboardingStatus: "config_choice",
|
||||
});
|
||||
|
||||
setProjectStatuses((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(newProject.id, "config_choice");
|
||||
return next;
|
||||
});
|
||||
setProjectOnboardingStates((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(newProject.id, null);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (redirectToNeonConfirmWith != null) {
|
||||
const confirmSearchParams = new URLSearchParams(redirectToNeonConfirmWith);
|
||||
confirmSearchParams.set("default_selected_project_id", newProject.id);
|
||||
router.push(`/integrations/neon/confirm?${confirmSearchParams.toString()}`);
|
||||
await wait(2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirectToConfirmWith != null) {
|
||||
const confirmSearchParams = new URLSearchParams(redirectToConfirmWith);
|
||||
confirmSearchParams.set("default_selected_project_id", newProject.id);
|
||||
router.push(`/integrations/custom/confirm?${confirmSearchParams.toString()}`);
|
||||
await wait(2000);
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams({
|
||||
project_id: newProject.id,
|
||||
mode: null,
|
||||
});
|
||||
} finally {
|
||||
endPendingAction(creatingProjectRef, setCreatingProject);
|
||||
}
|
||||
}, [projectName, redirectToConfirmWith, redirectToNeonConfirmWith, router, selectedTeamId, teams, updateSearchParams, user]);
|
||||
|
||||
const handleCreateProjectSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!hasProjectName || creatingProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
runAsynchronouslyWithAlert(createProject());
|
||||
}, [createProject, creatingProject, hasProjectName]);
|
||||
|
||||
const handleProjectNameKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (!hasProjectName || creatingProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
runAsynchronouslyWithAlert(createProject());
|
||||
}, [createProject, creatingProject, hasProjectName]);
|
||||
|
||||
const selectedProject = useMemo(() => {
|
||||
if (selectedProjectId == null) {
|
||||
return null;
|
||||
@ -278,108 +359,52 @@ function PageClientInner() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 px-6 py-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">Project name</Label>
|
||||
<DesignInput
|
||||
id="project-name"
|
||||
value={projectName}
|
||||
onChange={(event) => setProjectName(event.target.value)}
|
||||
placeholder="My Project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="team-id">Team</Label>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<DesignSelectorDropdown
|
||||
value={selectedTeamId ?? ""}
|
||||
onValueChange={setSelectedTeamId}
|
||||
placeholder="Select a team"
|
||||
size="md"
|
||||
className="w-full"
|
||||
options={teams.map((team) => ({ value: team.id, label: team.displayName }))}
|
||||
<form onSubmit={handleCreateProjectSubmit}>
|
||||
<div className="space-y-5 px-6 py-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">Project name</Label>
|
||||
<DesignInput
|
||||
id="project-name"
|
||||
value={projectName}
|
||||
onChange={(event) => setProjectName(event.target.value)}
|
||||
onKeyDown={handleProjectNameKeyDown}
|
||||
placeholder="My Project"
|
||||
/>
|
||||
<DesignButton variant="outline" onClick={() => setIsCreateTeamOpen(true)} className="rounded-xl sm:min-w-[152px]">
|
||||
<PlusCircleIcon className="mr-2 h-4 w-4" />
|
||||
Create Team
|
||||
</DesignButton>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="team-id">Team</Label>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<DesignSelectorDropdown
|
||||
value={selectedTeamId ?? ""}
|
||||
onValueChange={setSelectedTeamId}
|
||||
placeholder="Select a team"
|
||||
size="md"
|
||||
className="w-full"
|
||||
options={teams.map((team) => ({ value: team.id, label: team.displayName }))}
|
||||
/>
|
||||
<DesignButton type="button" variant="outline" onClick={() => setIsCreateTeamOpen(true)} className="rounded-xl sm:min-w-[152px]">
|
||||
<PlusCircleIcon className="mr-2 h-4 w-4" />
|
||||
Create Team
|
||||
</DesignButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-black/[0.08] px-6 py-4 dark:border-white/[0.08] sm:justify-end sm:space-x-2">
|
||||
<DesignButton variant="outline" className="rounded-xl" onClick={() => router.push("/projects")} disabled={creatingProject}>
|
||||
Cancel
|
||||
</DesignButton>
|
||||
<DesignButton
|
||||
className="rounded-xl"
|
||||
disabled={!hasProjectName || creatingProject}
|
||||
loading={creatingProject}
|
||||
onClick={() => {
|
||||
if (!beginPendingAction(creatingProjectRef, setCreatingProject)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return runAsynchronouslyWithAlert(async () => {
|
||||
const trimmedProjectName = projectName.trim();
|
||||
if (trimmedProjectName.length === 0) {
|
||||
throw new Error("Project name is required.");
|
||||
}
|
||||
|
||||
const firstTeam = teams.at(0);
|
||||
const teamId = selectedTeamId ?? user.selectedTeam?.id ?? firstTeam?.id;
|
||||
if (teamId === undefined) {
|
||||
throw new Error("Select a team before creating the project.");
|
||||
}
|
||||
|
||||
try {
|
||||
const newProject = await user.createProject({
|
||||
displayName: trimmedProjectName,
|
||||
teamId,
|
||||
onboardingStatus: "config_choice",
|
||||
});
|
||||
|
||||
setProjectStatuses((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(newProject.id, "config_choice");
|
||||
return next;
|
||||
});
|
||||
setProjectOnboardingStates((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(newProject.id, null);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (redirectToNeonConfirmWith != null) {
|
||||
const confirmSearchParams = new URLSearchParams(redirectToNeonConfirmWith);
|
||||
confirmSearchParams.set("default_selected_project_id", newProject.id);
|
||||
router.push(`/integrations/neon/confirm?${confirmSearchParams.toString()}`);
|
||||
await wait(2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirectToConfirmWith != null) {
|
||||
const confirmSearchParams = new URLSearchParams(redirectToConfirmWith);
|
||||
confirmSearchParams.set("default_selected_project_id", newProject.id);
|
||||
router.push(`/integrations/custom/confirm?${confirmSearchParams.toString()}`);
|
||||
await wait(2000);
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams({
|
||||
project_id: newProject.id,
|
||||
mode: null,
|
||||
});
|
||||
} finally {
|
||||
endPendingAction(creatingProjectRef, setCreatingProject);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
Create Project
|
||||
</DesignButton>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="border-t border-black/[0.08] px-6 py-4 dark:border-white/[0.08] sm:justify-end sm:space-x-2">
|
||||
<DesignButton type="button" variant="outline" className="rounded-xl" onClick={() => router.push("/projects")} disabled={creatingProject}>
|
||||
Cancel
|
||||
</DesignButton>
|
||||
<DesignButton
|
||||
type="submit"
|
||||
className="rounded-xl"
|
||||
disabled={!hasProjectName || creatingProject}
|
||||
loading={creatingProject}
|
||||
>
|
||||
Create Project
|
||||
</DesignButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@ -88,6 +88,21 @@ describe("new project page data loading", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("new project creation dialog", () => {
|
||||
it("uses native form submission for create-project keyboard access", () => {
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(join(
|
||||
testDir,
|
||||
"page-client-parts/content.tsx",
|
||||
), "utf-8");
|
||||
|
||||
expect(source).toContain("<form onSubmit={handleCreateProjectSubmit}>");
|
||||
expect(source).toContain("onKeyDown={handleProjectNameKeyDown}");
|
||||
expect(source).toContain('type="submit"');
|
||||
expect(source).toContain("Create Project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingPage", () => {
|
||||
it("uses hover-exit-only transitions and accessible labels for progress dots", () => {
|
||||
render(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user