fix: distinguish sidebar workspace types (#1364)
This commit is contained in:
@@ -24,6 +24,7 @@ export type Workspace = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
owner_id: string;
|
||||
office_workflow_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ function mapWorkspaceItem(ws: {
|
||||
default_environment_id?: string | null;
|
||||
default_agent_profile_id?: string | null;
|
||||
default_config_agent_profile_id?: string | null;
|
||||
office_workflow_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}) {
|
||||
@@ -49,6 +50,7 @@ function mapWorkspaceItem(ws: {
|
||||
default_environment_id: ws.default_environment_id ?? null,
|
||||
default_agent_profile_id: ws.default_agent_profile_id ?? null,
|
||||
default_config_agent_profile_id: ws.default_config_agent_profile_id ?? null,
|
||||
office_workflow_id: ws.office_workflow_id ?? null,
|
||||
created_at: ws.created_at,
|
||||
updated_at: ws.updated_at,
|
||||
};
|
||||
@@ -83,17 +85,19 @@ export default async function OfficeLayout({ children }: { children: React.React
|
||||
cookies().catch(() => null),
|
||||
]);
|
||||
|
||||
// Only show office workspaces (those with an office_workflow_id) in the rail.
|
||||
// The kanban's "Default Workspace" has no office_workflow_id and should not appear.
|
||||
const officeWorkspaces = workspacesResponse.workspaces.filter((ws) => ws.office_workflow_id);
|
||||
const workspaceItems = officeWorkspaces.map(mapWorkspaceItem);
|
||||
// Only office workspaces can be active inside /office, but hydrate every
|
||||
// workspace into the shared sidebar picker so users can switch back to Kanban.
|
||||
const officeWorkspaceItems = workspacesResponse.workspaces
|
||||
.filter((ws) => ws.office_workflow_id)
|
||||
.map(mapWorkspaceItem);
|
||||
const workspaceItems = workspacesResponse.workspaces.map(mapWorkspaceItem);
|
||||
const settingsWorkspaceId = userSettingsResponse?.settings?.workspace_id || null;
|
||||
// office-active-workspace cookie is set by the setup wizard and workspace rail so the
|
||||
// layout can load the correct workspace even when userSettings.workspace_id still points
|
||||
// to a kanban workspace (we never write to userSettings from office).
|
||||
const cookieWorkspaceId = cookieStore?.get("office-active-workspace")?.value ?? null;
|
||||
const activeWorkspaceId = resolveActiveOfficeWorkspaceId(
|
||||
workspaceItems,
|
||||
officeWorkspaceItems,
|
||||
cookieWorkspaceId,
|
||||
settingsWorkspaceId,
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ function mapWorkspaceItem(ws: WorkspaceItem) {
|
||||
default_environment_id: ws.default_environment_id ?? null,
|
||||
default_agent_profile_id: ws.default_agent_profile_id ?? null,
|
||||
default_config_agent_profile_id: ws.default_config_agent_profile_id ?? null,
|
||||
office_workflow_id: ws.office_workflow_id ?? null,
|
||||
created_at: ws.created_at,
|
||||
updated_at: ws.updated_at,
|
||||
};
|
||||
|
||||
@@ -35,11 +35,16 @@ vi.mock("@kandev/ui/dropdown-menu", () => ({
|
||||
const storeState = {
|
||||
features: { office: false },
|
||||
workspaces: {
|
||||
items: [{ id: "w1", name: "Default Workspace" }],
|
||||
items: [
|
||||
{ id: "w1", name: "Default Workspace", office_workflow_id: "" },
|
||||
{ id: "w2", name: "Office Workspace", office_workflow_id: "wf-office" },
|
||||
],
|
||||
activeId: "w1",
|
||||
},
|
||||
setActiveWorkspace: vi.fn(),
|
||||
};
|
||||
const KANBAN_WORKSPACE_ITEM = "sidebar-workspace-item-w1";
|
||||
const OFFICE_WORKSPACE_ITEM = "sidebar-workspace-item-w2";
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||
@@ -51,6 +56,7 @@ describe("AppSidebarWorkspacePicker — Add workspace routing", () => {
|
||||
beforeEach(() => {
|
||||
navigationMock.push = vi.fn();
|
||||
storeState.features.office = false;
|
||||
storeState.workspaces.activeId = "w1";
|
||||
storeState.setActiveWorkspace = vi.fn();
|
||||
});
|
||||
|
||||
@@ -62,12 +68,21 @@ describe("AppSidebarWorkspacePicker — Add workspace routing", () => {
|
||||
storeState.features.office = true;
|
||||
render(<AppSidebarWorkspacePicker />);
|
||||
|
||||
fireEvent.click(screen.getByText("Add workspace"));
|
||||
fireEvent.click(screen.getByText("New office workspace"));
|
||||
|
||||
expect(navigationMock.push).toHaveBeenCalledWith("/office/setup?mode=new");
|
||||
});
|
||||
|
||||
it("routes to the settings workspaces page when the office feature is disabled", () => {
|
||||
it("routes to the settings workspaces page for a new kanban workspace", () => {
|
||||
storeState.features.office = true;
|
||||
render(<AppSidebarWorkspacePicker />);
|
||||
|
||||
fireEvent.click(screen.getByText("New kanban workspace"));
|
||||
|
||||
expect(navigationMock.push).toHaveBeenCalledWith("/settings/workspace");
|
||||
});
|
||||
|
||||
it("keeps the legacy add-workspace route when the office feature is disabled", () => {
|
||||
storeState.features.office = false;
|
||||
render(<AppSidebarWorkspacePicker />);
|
||||
|
||||
@@ -85,6 +100,8 @@ describe("AppSidebarWorkspacePicker — workspace select", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
navigationMock.push = vi.fn();
|
||||
storeState.features.office = false;
|
||||
storeState.workspaces.activeId = "w1";
|
||||
storeState.setActiveWorkspace = vi.fn();
|
||||
cookieWrites = [];
|
||||
cookieDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, "cookie");
|
||||
@@ -104,14 +121,25 @@ describe("AppSidebarWorkspacePicker — workspace select", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("does nothing when selecting the already active workspace", () => {
|
||||
storeState.features.office = false;
|
||||
render(<AppSidebarWorkspacePicker />);
|
||||
|
||||
fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM));
|
||||
|
||||
expect(cookieWrites).toEqual([]);
|
||||
expect(storeState.setActiveWorkspace).not.toHaveBeenCalled();
|
||||
expect(navigationMock.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes the active-workspace cookie and updates the store on select", () => {
|
||||
storeState.features.office = false;
|
||||
render(<AppSidebarWorkspacePicker />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("sidebar-workspace-item-w1"));
|
||||
fireEvent.click(screen.getByTestId(OFFICE_WORKSPACE_ITEM));
|
||||
|
||||
expect(cookieWrites.some((c) => c.startsWith("office-active-workspace=w1"))).toBe(true);
|
||||
expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w1");
|
||||
expect(cookieWrites.some((c) => c.startsWith("office-active-workspace=w2"))).toBe(true);
|
||||
expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w2");
|
||||
expect(navigationMock.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -119,9 +147,40 @@ describe("AppSidebarWorkspacePicker — workspace select", () => {
|
||||
storeState.features.office = true;
|
||||
render(<AppSidebarWorkspacePicker />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("sidebar-workspace-item-w1"));
|
||||
fireEvent.click(screen.getByTestId(OFFICE_WORKSPACE_ITEM));
|
||||
|
||||
expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w2");
|
||||
expect(navigationMock.push).toHaveBeenCalledWith("/office?workspaceId=w2");
|
||||
});
|
||||
|
||||
it("navigates to the kanban board when an office user selects a kanban workspace", () => {
|
||||
storeState.features.office = true;
|
||||
storeState.workspaces.activeId = "w2";
|
||||
render(<AppSidebarWorkspacePicker />);
|
||||
|
||||
fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM));
|
||||
|
||||
expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w1");
|
||||
expect(navigationMock.push).toHaveBeenCalledWith("/office?workspaceId=w1");
|
||||
expect(navigationMock.push).toHaveBeenCalledWith("/?workspaceId=w1");
|
||||
});
|
||||
|
||||
it("labels workspace types in the menu without using trigger space", () => {
|
||||
storeState.workspaces.activeId = "w1";
|
||||
render(<AppSidebarWorkspacePicker />);
|
||||
|
||||
expect(screen.getByTestId("sidebar-workspace-trigger").textContent).not.toContain("Kanban");
|
||||
expect(screen.getByTestId(KANBAN_WORKSPACE_ITEM).textContent).toContain("Kanban");
|
||||
expect(screen.getByTestId(OFFICE_WORKSPACE_ITEM).textContent).toContain("Office");
|
||||
});
|
||||
|
||||
it("still no-ops on the active workspace when office routing is enabled", () => {
|
||||
storeState.features.office = true;
|
||||
render(<AppSidebarWorkspacePicker />);
|
||||
|
||||
fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM));
|
||||
|
||||
expect(cookieWrites).toEqual([]);
|
||||
expect(storeState.setActiveWorkspace).not.toHaveBeenCalled();
|
||||
expect(navigationMock.push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import { forwardRef, useCallback, useState, type ComponentPropsWithoutRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IconCheck, IconChevronDown, IconPlus } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBriefcase,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconLayoutKanban,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -45,6 +51,29 @@ const WorkspaceTrigger = forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
type WorkspaceType = "kanban" | "office";
|
||||
|
||||
type WorkspaceItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
office_workflow_id?: string | null;
|
||||
};
|
||||
|
||||
function workspaceType(workspace: WorkspaceItem | undefined): WorkspaceType {
|
||||
return workspace?.office_workflow_id ? "office" : "kanban";
|
||||
}
|
||||
|
||||
function workspaceTypeLabel(type: WorkspaceType) {
|
||||
return type === "office" ? "Office" : "Kanban";
|
||||
}
|
||||
|
||||
function WorkspaceTypeIcon({ type, className }: { type: WorkspaceType; className: string }) {
|
||||
if (type === "office") {
|
||||
return <IconBriefcase className={className} />;
|
||||
}
|
||||
return <IconLayoutKanban className={className} />;
|
||||
}
|
||||
|
||||
export function AppSidebarWorkspacePicker() {
|
||||
const router = useRouter();
|
||||
const officeEnabled = useFeature("office");
|
||||
@@ -57,15 +86,21 @@ export function AppSidebarWorkspacePicker() {
|
||||
const activeName = activeWorkspace?.name ?? "Workspace";
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
(workspace: WorkspaceItem) => {
|
||||
const { id } = workspace;
|
||||
if (id === activeId) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
document.cookie = `office-active-workspace=${id}; path=/; max-age=86400; samesite=strict; secure`;
|
||||
setActiveWorkspace(id);
|
||||
if (officeEnabled) {
|
||||
router.push(`/office?workspaceId=${id}`);
|
||||
const target = workspaceType(workspace) === "office" ? "/office" : "/";
|
||||
router.push(`${target}?workspaceId=${id}`);
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
[router, setActiveWorkspace, officeEnabled],
|
||||
[activeId, router, setActiveWorkspace, officeEnabled],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -73,32 +108,59 @@ export function AppSidebarWorkspacePicker() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<WorkspaceTrigger activeName={activeName} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
{workspaces.items.length === 0 ? (
|
||||
<DropdownMenuItem disabled>No workspaces</DropdownMenuItem>
|
||||
) : (
|
||||
workspaces.items.map((ws) => (
|
||||
<DropdownMenuItem
|
||||
key={ws.id}
|
||||
data-testid={`sidebar-workspace-item-${ws.id}`}
|
||||
onSelect={() => handleSelect(ws.id)}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
<span className="flex-1 truncate">{ws.name}</span>
|
||||
{ws.id === activeId && <IconCheck className="h-3.5 w-3.5" />}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
workspaces.items.map((ws) => {
|
||||
const type = workspaceType(ws);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={ws.id}
|
||||
data-testid={`sidebar-workspace-item-${ws.id}`}
|
||||
onSelect={() => handleSelect(ws)}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
<WorkspaceTypeIcon
|
||||
type={type}
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{ws.name}</span>
|
||||
<span className="shrink-0 rounded border border-border/60 px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground">
|
||||
{workspaceTypeLabel(type)}
|
||||
</span>
|
||||
{ws.id === activeId && <IconCheck className="h-3.5 w-3.5 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2"
|
||||
onSelect={() => {
|
||||
router.push(officeEnabled ? "/office/setup?mode=new" : "/settings/workspace");
|
||||
}}
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
<span>Add workspace</span>
|
||||
</DropdownMenuItem>
|
||||
{officeEnabled ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2"
|
||||
onSelect={() => router.push("/settings/workspace")}
|
||||
>
|
||||
<IconLayoutKanban className="h-3.5 w-3.5" />
|
||||
<span>New kanban workspace</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2"
|
||||
onSelect={() => router.push("/office/setup?mode=new")}
|
||||
>
|
||||
<IconBriefcase className="h-3.5 w-3.5" />
|
||||
<span>New office workspace</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2"
|
||||
onSelect={() => router.push("/settings/workspace")}
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
<span>Add workspace</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -74,17 +74,17 @@ test.describe("Onboarding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('"Add workspace" button opens setup and close returns to homepage', async ({
|
||||
test('"New office workspace" opens setup and close returns to homepage', async ({
|
||||
testPage,
|
||||
officeSeed: _,
|
||||
}) => {
|
||||
await testPage.goto("/office");
|
||||
// The unified AppSidebar overhaul folded the workspace switcher into a
|
||||
// dropdown in the sidebar header. "Add workspace" is now a menu item
|
||||
// (role=menuitem) inside that dropdown, reached by opening the picker
|
||||
// via its `sidebar-workspace-trigger` button.
|
||||
// dropdown in the sidebar header. New office workspace is a menu item
|
||||
// inside that dropdown, reached by opening the picker via its
|
||||
// `sidebar-workspace-trigger` button.
|
||||
await testPage.getByTestId("sidebar-workspace-trigger").click();
|
||||
await testPage.getByRole("menuitem", { name: "Add workspace" }).click();
|
||||
await testPage.getByRole("menuitem", { name: "New office workspace" }).click();
|
||||
await expect(testPage).toHaveURL(/\/office\/setup\?mode=new/, { timeout: 10_000 });
|
||||
await expect(
|
||||
testPage.getByRole("heading", { name: "Set up your Office workspace" }),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from "../../fixtures/office-fixture";
|
||||
|
||||
test.describe("Office workspace picker", () => {
|
||||
test("labels workspace types and keeps the current workspace selection as a no-op", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
officeSeed,
|
||||
}) => {
|
||||
const kanbanWorkspace = await apiClient.createWorkspace("Picker Kanban Workspace");
|
||||
|
||||
await testPage.goto("/office");
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage).toHaveURL(/\/office$/);
|
||||
|
||||
await testPage.getByTestId("sidebar-workspace-trigger").click();
|
||||
|
||||
const kanbanWorkspaceItem = testPage.getByTestId(
|
||||
`sidebar-workspace-item-${kanbanWorkspace.id}`,
|
||||
);
|
||||
const officeWorkspace = testPage.getByTestId(
|
||||
`sidebar-workspace-item-${officeSeed.workspaceId}`,
|
||||
);
|
||||
await expect(kanbanWorkspaceItem).toContainText("Kanban");
|
||||
await expect(officeWorkspace).toContainText("Office");
|
||||
|
||||
await officeWorkspace.click();
|
||||
|
||||
await expect(testPage).toHaveURL(/\/office$/);
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await testPage.goto("/office");
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
|
||||
await testPage.getByTestId("sidebar-workspace-trigger").click();
|
||||
const reopenedKanbanWorkspaceItem = testPage.getByTestId(
|
||||
`sidebar-workspace-item-${kanbanWorkspace.id}`,
|
||||
);
|
||||
await expect(reopenedKanbanWorkspaceItem).toBeVisible();
|
||||
await reopenedKanbanWorkspaceItem.click();
|
||||
await expect(testPage).toHaveURL(
|
||||
(url) => {
|
||||
return url.pathname === "/" && url.searchParams.get("workspaceId") === kanbanWorkspace.id;
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("offers separate kanban and office workspace creation actions", async ({
|
||||
testPage,
|
||||
officeSeed: _,
|
||||
}) => {
|
||||
await testPage.goto("/office");
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await testPage.getByTestId("sidebar-workspace-trigger").click();
|
||||
await testPage.getByRole("menuitem", { name: "New office workspace" }).click();
|
||||
await expect(testPage).toHaveURL(/\/office\/setup\?mode=new/, { timeout: 10_000 });
|
||||
await expect(
|
||||
testPage.getByRole("heading", { name: "Set up your Office workspace" }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await testPage.goto("/office");
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await testPage.getByTestId("sidebar-workspace-trigger").click();
|
||||
await testPage.getByRole("menuitem", { name: "New kanban workspace" }).click();
|
||||
await expect(testPage).toHaveURL(/\/settings\/workspace$/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ export type WorkspaceState = {
|
||||
default_environment_id?: string | null;
|
||||
default_agent_profile_id?: string | null;
|
||||
default_config_agent_profile_id?: string | null;
|
||||
office_workflow_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
|
||||
Reference in New Issue
Block a user