feat: add project settings page navigation

- Remove inline edit modal from projects listing page
- Add Settings link to project cards navigating to /projects/:id/settings
- Reposition Open Workspace button to rightmost action for easier access
- Update tests for new UI flow
- Update documentation to reflect new editing workflow
- Sync specs: frontend-foundation and project-management

Quality gates: npm run lint passed
This commit is contained in:
2026-05-22 18:43:40 +00:00
parent d0e5feeaa5
commit fb0f2f7b9b
11 changed files with 276 additions and 85 deletions
+25 -14
View File
@@ -108,10 +108,8 @@ describe("ProjectsPage", () => {
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
});
it("opens edit dialog and saves changes", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
it("renders settings link for each project", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
await waitFor(() => {
@@ -121,20 +119,33 @@ describe("ProjectsPage", () => {
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const settingsLink = within(alphaCard).getByRole("link", { name: /settings/i });
expect(settingsLink).toBeInTheDocument();
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
});
const nameInput = screen.getByDisplayValue("Alpha Project");
fireEvent.change(nameInput, { target: { value: "Alpha Updated" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
it("renders open workspace link as rightmost action", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
await waitFor(() => {
expect(updateMock).toHaveBeenCalledWith("proj-1", {
name: "Alpha Updated",
description: "First project",
});
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
expect(listMock).toHaveBeenCalledTimes(2);
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
const actions = alphaCard.querySelector(".project-actions");
if (!actions) throw new Error("Actions container not found");
const workspaceLink = within(alphaCard).getByRole("link", { name: /open workspace/i });
expect(workspaceLink).toBeInTheDocument();
expect(workspaceLink).toHaveAttribute("href", "/projects/proj-1");
// Verify it's the last action in the container
const allActions = actions.querySelectorAll("a, button");
const lastAction = allActions[allActions.length - 1];
expect(lastAction).toBe(workspaceLink);
});
it("shows delete confirmation and deletes project", async () => {
+25 -57
View File
@@ -6,21 +6,17 @@ import {
createProject,
deleteProject,
listProjects,
updateProject,
type ProjectCreateInput,
type ProjectUpdateInput,
} from "../api/projects";
import { Icon } from "../components/icon";
import type { Project } from "../types";
type ProjectsStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ProjectsPage = () => {
const [status, setStatus] = useState<ProjectsStatus>("loading");
const [projects, setProjects] = useState<Project[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
@@ -46,25 +42,15 @@ export const ProjectsPage = () => {
setFormName("");
setFormDescription("");
setFormError(null);
setEditingProject(null);
setDialogMode("create");
setShowCreate(true);
};
const openEdit = (project: Project) => {
setFormName(project.name);
setFormDescription(project.description ?? "");
setFormError(null);
setEditingProject(project);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingProject(null);
const closeCreate = () => {
setShowCreate(false);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
@@ -74,20 +60,12 @@ export const ProjectsPage = () => {
}
try {
if (dialogMode === "create") {
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
} else if (dialogMode === "edit" && editingProject) {
const input: ProjectUpdateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await updateProject(editingProject.id, input);
}
closeDialog();
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
closeCreate();
await loadProjects();
} catch {
setFormError("Failed to save project");
@@ -139,17 +117,13 @@ export const ProjectsPage = () => {
{project.description && <p className="muted">{project.description}</p>}
</div>
<div className="project-actions">
<Link className="ghost-button" to={`/projects/${project.id}`}>
Open Workspace
</Link>
<button
<Link
className="ghost-button"
onClick={() => openEdit(project)}
type="button"
to={`/projects/${project.id}/settings`}
>
<Icon name="edit" size="sm" />
Edit
</button>
<Icon name="settings" size="sm" />
Settings
</Link>
{deleteConfirmId === project.id ? (
<div className="delete-confirm">
<span>Are you sure?</span>
@@ -180,17 +154,20 @@ export const ProjectsPage = () => {
Delete
</button>
)}
<Link className="ghost-button" to={`/projects/${project.id}`}>
Open Workspace
</Link>
</div>
</article>
))}
</div>
)}
{dialogMode !== "none" && (
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
<form onSubmit={handleSubmit} className="stack">
<h2>Create Project</h2>
<form onSubmit={handleCreate} className="stack">
<label className="form-field">
Name
<input
@@ -211,22 +188,13 @@ export const ProjectsPage = () => {
</label>
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={closeDialog} type="button">
<button className="secondary-button" onClick={closeCreate} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
<Icon name="add" size="sm" />
Create
</button>
</div>
</form>