Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions react-compiler.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [
"src/components/Learn",
"src/components/Onboarding",
"src/components/Project",
"src/components/WorkspaceSecrets",

// 0 useCallback/useMemo - ready to enable
"src/components/layout",
Expand Down Expand Up @@ -52,6 +53,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [
"src/services/projects/usePipelineProjects.ts",
"src/services/projects/useProjectInstructions.ts",
"src/services/projects/useProjectRuns.ts",
"src/services/projects/useWorkspaceSecrets.ts",
"src/services/usePipelineSpec.ts",
"src/components/shared/FavoriteToggle.tsx",
"src/components/shared/PageHeader.tsx",
Expand Down
123 changes: 113 additions & 10 deletions src/components/Home/ProjectsSection/CreateProjectDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { rememberLastUsedWorkspace } from "@/services/projects/defaultWorkspace";
import type { Workspace } from "@/services/projects/types";
import { useCreateProject } from "@/services/projects/useProjects";

import { CreateProjectDialog } from "./CreateProjectDialog";
Expand Down Expand Up @@ -38,11 +40,29 @@ function mockCreateProject({ isPending = false } = {}) {
} as unknown as ReturnType<typeof useCreateProject>);
}

async function openDialog(workspaceId = "workspace-1") {
const workspace = (
id: string,
name: string,
createdAt: string,
overrides: Partial<Workspace> = {},
): Workspace => ({
id,
name,
description: null,
isActive: true,
metadata: null,
createdAt: new Date(createdAt),
...overrides,
});

const research = workspace("workspace-1", "Research", "2026-01-01T00:00:00Z");
const analytics = workspace("workspace-2", "Analytics", "2026-06-01T00:00:00Z");

async function openDialog(workspaces: Workspace[] = [analytics, research]) {
const user = userEvent.setup();
render(
<CreateProjectDialog
workspaceId={workspaceId}
workspaces={workspaces}
trigger={<button>New Project</button>}
/>,
);
Expand All @@ -52,6 +72,14 @@ async function openDialog(workspaceId = "workspace-1") {

const submitButton = () => screen.getByRole("button", { name: "Create" });

async function pickWorkspace(
user: ReturnType<typeof userEvent.setup>,
name: string,
) {
await user.click(screen.getByLabelText("Workspace"));
await user.click(screen.getByRole("option", { name }));
}

describe("CreateProjectDialog", () => {
beforeEach(() => {
// jsdom implements neither, and Radix's select calls both while opening.
Expand Down Expand Up @@ -82,15 +110,89 @@ describe("CreateProjectDialog", () => {
expect(mutate).not.toHaveBeenCalled();
});

it("never asks which workspace to use", async () => {
it("starts on the oldest workspace rather than the first listed", async () => {
await openDialog();

expect(screen.getByLabelText("Workspace")).toHaveTextContent("Research");
});

it("starts on the workspace last created in", async () => {
rememberLastUsedWorkspace("workspace-2");

await openDialog();

expect(screen.getByLabelText("Workspace")).toHaveTextContent("Analytics");
});

it("asks for a workspace even when there is only one", async () => {
await openDialog([research]);

expect(screen.getByLabelText("Workspace")).toHaveTextContent("Research");
});

it("warns that the workspace is permanent", async () => {
await openDialog();

expect(screen.getByLabelText("Workspace")).toHaveAccessibleDescription(
"The workspace cannot be changed after the project is created.",
);
});

it("files the project in the workspace that was picked", async () => {
const user = await openDialog();

await user.type(screen.getByLabelText("Name"), "Churn model");
await pickWorkspace(user, "Analytics");
await user.click(submitButton());

expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: "workspace-2" }),
expect.anything(),
);
});

it("remembers the workspace for the next project", async () => {
const user = await openDialog();

await user.type(screen.getByLabelText("Name"), "Churn model");
await pickWorkspace(user, "Analytics");
await user.click(submitButton());

expect(screen.queryByLabelText("Workspace")).toBeNull();
expect(screen.queryByRole("combobox")).toBeNull();
expect(screen.queryByText(/workspace/i)).toBeNull();
expect(submitButton()).toBeEnabled();
const [, options] = mutate.mock.calls[0];
options.onSuccess({ id: "project-9" });

expect(localStorage.getItem("tangle.projects.lastUsedWorkspaceId")).toBe(
JSON.stringify("workspace-2"),
);
});

it("forgets a pick that was cancelled", async () => {
const user = await openDialog();

await pickWorkspace(user, "Analytics");
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.click(screen.getByRole("button", { name: /New Project/ }));

expect(screen.getByLabelText("Workspace")).toHaveTextContent("Research");
expect(
localStorage.getItem("tangle.projects.lastUsedWorkspaceId"),
).toBeNull();
});

it("records whether the preselected workspace was changed", async () => {
const user = await openDialog();

await user.type(screen.getByLabelText("Name"), "Churn model");
await pickWorkspace(user, "Analytics");
await user.click(submitButton());

const [, options] = mutate.mock.calls[0];
options.onSuccess({ id: "project-9" });

expect(track).toHaveBeenCalledWith("projects.create_project_completed", {
has_description: false,
workspace_changed: true,
});
});

it("creates a project with a trimmed name and no description", async () => {
Expand All @@ -109,8 +211,8 @@ describe("CreateProjectDialog", () => {
);
});

it("files the project in whichever workspace it was handed", async () => {
const user = await openDialog("workspace-from-backend");
it("creates a project with its description", async () => {
const user = await openDialog();

await user.type(screen.getByLabelText("Name"), "Churn model");
await user.type(
Expand All @@ -121,7 +223,7 @@ describe("CreateProjectDialog", () => {

expect(mutate).toHaveBeenCalledWith(
{
workspaceId: "workspace-from-backend",
workspaceId: "workspace-1",
name: "Churn model",
description: "Q3 churn work",
},
Expand All @@ -141,6 +243,7 @@ describe("CreateProjectDialog", () => {
expect(notify).toHaveBeenCalledWith("Project created", "success");
expect(track).toHaveBeenCalledWith("projects.create_project_completed", {
has_description: false,
workspace_changed: false,
});
await waitFor(() => {
expect(screen.queryByLabelText("Name")).toBeNull();
Expand Down
52 changes: 48 additions & 4 deletions src/components/Home/ProjectsSection/CreateProjectDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,39 @@ import { Icon } from "@/components/ui/icon";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Text } from "@/components/ui/typography";
import useToastNotification from "@/hooks/useToastNotification";
import { useAnalytics } from "@/providers/AnalyticsProvider";
import { getProjectHomeRoute } from "@/routes/projectRoutes";
import { rememberLastUsedWorkspace } from "@/services/projects/defaultWorkspace";
import type { Workspace } from "@/services/projects/types";
import { useCreateProject } from "@/services/projects/useProjects";
import { tracking } from "@/utils/tracking";

import { useWorkspaceChoice } from "./useWorkspaceChoice";

interface CreateProjectDialogProps {
workspaceId: string;
workspaces: readonly Workspace[];
trigger: ReactNode;
}

export function CreateProjectDialog({
workspaceId,
workspaces,
trigger,
}: CreateProjectDialogProps) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [submitAttempted, setSubmitAttempted] = useState(false);
const [description, setDescription] = useState("");
const workspaceChoice = useWorkspaceChoice(workspaces);

const createProject = useCreateProject();
const notify = useToastNotification();
Expand All @@ -59,6 +72,7 @@ export function CreateProjectDialog({
setName("");
setSubmitAttempted(false);
setDescription("");
workspaceChoice.reset();
};

const handleOpenChange = (nextOpen: boolean) => {
Expand All @@ -69,20 +83,24 @@ export function CreateProjectDialog({
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
setSubmitAttempted(true);
if (!canSubmit) return;
const workspace = workspaceChoice.selected;
if (!canSubmit || !workspace) return;

const trimmedDescription = description.trim();
const workspaceChanged = workspaceChoice.changedFromDefault;

createProject.mutate(
{
workspaceId,
workspaceId: workspace.id,
name: trimmedName,
description: trimmedDescription === "" ? undefined : trimmedDescription,
},
{
onSuccess: (project) => {
rememberLastUsedWorkspace(workspace.id);
track("projects.create_project_completed", {
has_description: trimmedDescription !== "",
workspace_changed: workspaceChanged,
});
notify("Project created", "success");
resetForm();
Expand Down Expand Up @@ -125,6 +143,32 @@ export function CreateProjectDialog({
)}
</BlockStack>

<BlockStack gap="2">
<Label htmlFor="create-project-workspace">Workspace</Label>
<Select
value={workspaceChoice.selected?.id ?? ""}
onValueChange={workspaceChoice.choose}
>
<SelectTrigger
id="create-project-workspace"
aria-describedby="create-project-workspace-hint"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{workspaceChoice.workspaces.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Text id="create-project-workspace-hint" size="xs" tone="subdued">
The workspace cannot be changed after the project is created.
</Text>
</BlockStack>

<BlockStack gap="2">
<Label htmlFor="create-project-description">
Description (optional)
Expand Down
43 changes: 40 additions & 3 deletions src/components/Home/ProjectsSection/ProjectCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { useProjectPin } from "@/components/Project/useProjectPin";
import { useFlagValue } from "@/components/shared/Settings/useFlags";
import type { ProjectSummary } from "@/services/projects/types";
import type { ProjectSummary, Workspace } from "@/services/projects/types";
import { useDeleteProject } from "@/services/projects/useProjects";
import { useWorkspaces } from "@/services/projects/useWorkspaces";
import { copyToClipboard } from "@/utils/string";

import { ProjectCard } from "./ProjectCard";
Expand Down Expand Up @@ -64,6 +65,25 @@ vi.mock("@/utils/URL", () => ({
getProjectUrl: (id: string) => `https://tangle.example/projects/${id}`,
}));

vi.mock("@/services/projects/useWorkspaces", () => ({
useWorkspaces: vi.fn(),
}));

const workspace: Workspace = {
id: "workspace-1",
name: "ML Research",
description: null,
isActive: true,
metadata: null,
createdAt: new Date("2026-09-01T10:00:00Z"),
};

function mockWorkspaces(workspaces: Workspace[] | undefined) {
vi.mocked(useWorkspaces).mockReturnValue({
data: workspaces,
} as ReturnType<typeof useWorkspaces>);
}

const project: ProjectSummary = {
id: "project-1",
workspaceId: "workspace-1",
Expand Down Expand Up @@ -120,6 +140,7 @@ describe("ProjectCard", () => {
mockDeleteProject();
mockPin();
mockFlags({ "tangent-shell": true });
mockWorkspaces([workspace]);
});

afterEach(() => {
Expand Down Expand Up @@ -296,10 +317,26 @@ describe("ProjectCard", () => {
expect(togglePin).toHaveBeenCalled();
});

it("says nothing about the workspace a project lives in", () => {
it("names the workspace the project lives in", () => {
renderCard();

expect(screen.getByLabelText("Workspace")).toBeInTheDocument();
expect(screen.getByText("ML Research")).toBeInTheDocument();
expect(screen.queryByText("workspace-1")).toBeNull();
});

it("names a workspace that has since been retired", () => {
mockWorkspaces([{ ...workspace, isActive: false }]);
renderCard();

expect(screen.getByText("ML Research")).toBeInTheDocument();
});

it("leaves the workspace out until the list says what it is called", () => {
mockWorkspaces(undefined);
renderCard();

expect(screen.queryByText("ML Research")).toBeNull();
expect(screen.queryByLabelText("Workspace")).toBeNull();
expect(screen.queryByText("workspace-1")).toBeNull();
});

Expand Down
Loading
Loading