mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Polish operator sidebar and issue property controls (#5355)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Operators use the board sidebar and issue properties panel to move between companies and understand task metadata > - Small UI regressions in these controls make repeated board operation slower and less predictable > - The local branch already contained targeted fixes for company ordering, issue date display, and sidebar rail sizing > - This pull request isolates those operator UI quality-of-life fixes into a standalone branch against `origin/master` > - The benefit is a focused, reviewable PR that can merge independently of the issue-thread activity work ## What Changed - Shows issue property timestamps with time, not just dates. - Adds edit-mode support for ordering companies in the sidebar company menu. - Fixes a workspace switcher rail regression and keeps the account menu aligned with the rail width. - Includes focused component coverage for the touched controls. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx ui/src/components/SidebarCompanyMenu.test.tsx ui/src/components/Layout.test.tsx ui/src/components/SidebarAccountMenu.test.tsx` — 4 files passed, 29 tests passed. - `pnpm --filter /ui typecheck` - PR checks on `a4030f7a` are green: policy, verify, serialized server suites 1/4-4/4, e2e, Canary Dry Run, Greptile Review, and Snyk. - Captured a local Storybook screenshot of `Product/Navigation & Layout` after the sidebar polish: `/tmp/pap-3659-screenshots/navigation-layout-after.png`. - Confirmed the PR changes 8 files and does not include `pnpm-lock.yaml` or `.github/workflows/*`. ## Risks - Low to moderate UI risk: this touches shared sidebar components and issue metadata rendering. - The company ordering behavior depends on existing query/cache behavior, so stale cache bugs would show up as ordering inconsistencies. - No database, API, workflow, or lockfile changes are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, shell/tool-use enabled, used to split the existing branch, verify the isolated PR branch, and create this PR. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
68f69975a4
commit
4103978578
10 changed files with 297 additions and 313 deletions
|
|
@ -1,260 +0,0 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { Paperclip, Plus } from "lucide-react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
MouseSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { authApi } from "../api/auth";
|
||||
import { useCompanyOrder } from "../hooks/useCompanyOrder";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
||||
|
||||
function SortableCompanyItem({
|
||||
company,
|
||||
isSelected,
|
||||
hasLiveAgents,
|
||||
hasUnreadInbox,
|
||||
onSelect,
|
||||
}: {
|
||||
company: Company;
|
||||
isSelected: boolean;
|
||||
hasLiveAgents: boolean;
|
||||
hasUnreadInbox: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: company.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="overflow-visible">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={`/${company.issuePrefix}/dashboard`}
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}}
|
||||
className="relative flex items-center justify-center group overflow-visible"
|
||||
>
|
||||
{/* Selection indicator pill */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-[height] duration-150",
|
||||
isSelected
|
||||
? "h-5"
|
||||
: "h-0 group-hover:h-2"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn("relative overflow-visible transition-transform duration-150", isDragging && "scale-105")}
|
||||
>
|
||||
<CompanyPatternIcon
|
||||
companyName={company.name}
|
||||
logoUrl={company.logoUrl}
|
||||
brandColor={company.brandColor}
|
||||
className={cn(
|
||||
isSelected
|
||||
? "rounded-[14px]"
|
||||
: "rounded-[22px] group-hover:rounded-[14px]",
|
||||
isDragging && "shadow-lg",
|
||||
)}
|
||||
/>
|
||||
{hasLiveAgents && (
|
||||
<span className="pointer-events-none absolute -right-0.5 -top-0.5 z-10">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-80" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500 ring-2 ring-background" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{hasUnreadInbox && (
|
||||
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 z-10 h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-background" />
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>{company.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanyRail() {
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isInstanceRoute = location.pathname.startsWith("/instance/");
|
||||
const highlightedCompanyId = isInstanceRoute ? null : selectedCompanyId;
|
||||
const sidebarCompanies = useMemo(
|
||||
() => companies.filter((company) => company.status !== "archived"),
|
||||
[companies],
|
||||
);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]);
|
||||
|
||||
const liveRunsQueries = useQueries({
|
||||
queries: companyIds.map((companyId) => ({
|
||||
queryKey: queryKeys.liveRuns(companyId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||||
refetchInterval: 10_000,
|
||||
})),
|
||||
});
|
||||
const sidebarBadgeQueries = useQueries({
|
||||
queries: companyIds.map((companyId) => ({
|
||||
queryKey: queryKeys.sidebarBadges(companyId),
|
||||
queryFn: () => sidebarBadgesApi.get(companyId),
|
||||
refetchInterval: 15_000,
|
||||
})),
|
||||
});
|
||||
const hasLiveAgentsByCompanyId = useMemo(() => {
|
||||
const result = new Map<string, boolean>();
|
||||
companyIds.forEach((companyId, index) => {
|
||||
result.set(companyId, (liveRunsQueries[index]?.data?.length ?? 0) > 0);
|
||||
});
|
||||
return result;
|
||||
}, [companyIds, liveRunsQueries]);
|
||||
const hasUnreadInboxByCompanyId = useMemo(() => {
|
||||
const result = new Map<string, boolean>();
|
||||
companyIds.forEach((companyId, index) => {
|
||||
result.set(companyId, (sidebarBadgeQueries[index]?.data?.inbox ?? 0) > 0);
|
||||
});
|
||||
return result;
|
||||
}, [companyIds, sidebarBadgeQueries]);
|
||||
|
||||
const { orderedCompanies, persistOrder } = useCompanyOrder({
|
||||
companies: sidebarCompanies,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||
const sensors = useSensors(
|
||||
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const ids = orderedCompanies.map((c) => c.id);
|
||||
const oldIndex = ids.indexOf(active.id as string);
|
||||
const newIndex = ids.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
persistOrder(arrayMove(ids, oldIndex, newIndex));
|
||||
},
|
||||
[orderedCompanies, persistOrder]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-[72px] shrink-0 h-full bg-background border-r border-border">
|
||||
{/* Paperclip icon - aligned with top sections (implied line, no visible border) */}
|
||||
<div className="flex items-center justify-center h-12 w-full shrink-0">
|
||||
<Paperclip className="h-5 w-5 text-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Company list */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2 py-3 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedCompanies.map((c) => c.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{orderedCompanies.map((company) => (
|
||||
<SortableCompanyItem
|
||||
key={company.id}
|
||||
company={company}
|
||||
isSelected={company.id === highlightedCompanyId}
|
||||
hasLiveAgents={hasLiveAgentsByCompanyId.get(company.id) ?? false}
|
||||
hasUnreadInbox={hasUnreadInboxByCompanyId.get(company.id) ?? false}
|
||||
onSelect={() => {
|
||||
setSelectedCompanyId(company.id);
|
||||
if (isInstanceRoute) {
|
||||
navigate(`/${company.issuePrefix}/dashboard`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Separator before add button */}
|
||||
<div className="w-8 h-px bg-border mx-auto shrink-0" />
|
||||
|
||||
{/* Add company button */}
|
||||
<div className="flex items-center justify-center py-2 shrink-0">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => openOnboarding()}
|
||||
className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-[14px] border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-[border-color,color,border-radius] duration-150"
|
||||
aria-label="Add company"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>Add company</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -559,6 +559,25 @@ describe("IssueProperties", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows full date and time for issue metadata timestamps", async () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
createdAt: new Date(2026, 3, 6, 12, 34),
|
||||
startedAt: new Date(2026, 3, 6, 12, 35),
|
||||
completedAt: new Date(2026, 3, 6, 12, 36),
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toMatch(/CreatedApr 6, 2026, \d{1,2}:34 (AM|PM)/);
|
||||
expect(container.textContent).toMatch(/StartedApr 6, 2026, \d{1,2}:35 (AM|PM)/);
|
||||
expect(container.textContent).toMatch(/CompletedApr 6, 2026, \d{1,2}:36 (AM|PM)/);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows a workspace tasks link for non-default workspaces when isolated workspaces are enabled", async () => {
|
||||
mockProjectsApi.list.mockResolvedValue([createProject()]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { StatusIcon } from "./StatusIcon";
|
|||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
import { IssueReferencePill } from "./IssueReferencePill";
|
||||
import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||
import { formatDate, formatDateTime, cn, projectUrl } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -1592,16 +1592,16 @@ export function IssueProperties({
|
|||
)}
|
||||
{issue.startedAt && (
|
||||
<PropertyRow label="Started">
|
||||
<span className="text-sm">{formatDate(issue.startedAt)}</span>
|
||||
<span className="text-sm">{formatDateTime(issue.startedAt)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.completedAt && (
|
||||
<PropertyRow label="Completed">
|
||||
<span className="text-sm">{formatDate(issue.completedAt)}</span>
|
||||
<span className="text-sm">{formatDateTime(issue.completedAt)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Created">
|
||||
<span className="text-sm">{formatDate(issue.createdAt)}</span>
|
||||
<span className="text-sm">{formatDateTime(issue.createdAt)}</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Updated">
|
||||
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
|
||||
|
|
|
|||
|
|
@ -40,10 +40,6 @@ vi.mock("@/lib/router", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("./CompanyRail", () => ({
|
||||
CompanyRail: () => <div>Company rail</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./Sidebar", () => ({
|
||||
Sidebar: () => <div>Main company nav</div>,
|
||||
}));
|
||||
|
|
@ -260,6 +256,7 @@ describe("Layout", () => {
|
|||
expect(mockHealthApi.get).toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Breadcrumbs");
|
||||
expect(container.textContent).toContain("Outlet content");
|
||||
expect(container.textContent).not.toContain("Company rail");
|
||||
expect(container.textContent).not.toContain("Authenticated private");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Sign-in is required and this instance is intended for private-network access.",
|
||||
|
|
@ -312,6 +309,7 @@ describe("Layout", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Company rail");
|
||||
expect(container.textContent).not.toContain("Instance sidebar");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
|
|
@ -339,6 +337,7 @@ describe("Layout", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Instance sidebar");
|
||||
expect(container.textContent).not.toContain("Company rail");
|
||||
expect(container.textContent).not.toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { InstanceSidebar } from "./InstanceSidebar";
|
||||
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||
|
|
@ -378,7 +377,6 @@ export function Layout() {
|
|||
)}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<CompanyRail />
|
||||
<div className="w-60 shrink-0 overflow-hidden">
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
|
|
@ -398,7 +396,6 @@ export function Layout() {
|
|||
) : (
|
||||
<div className="flex h-full flex-col shrink-0">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<CompanyRail />
|
||||
<ResizableSidebarPane open={sidebarOpen} resizable className="h-full shrink-0">
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ describe("SidebarAccountMenu", () => {
|
|||
expect(document.body.textContent).toContain("Documentation");
|
||||
expect(document.body.textContent).toContain("Paperclip v1.2.3");
|
||||
expect(document.body.textContent).toContain("jane@example.com");
|
||||
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
|
||||
.toContain("w-[var(--radix-popover-trigger-width)]");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ export function SidebarAccountMenu({
|
|||
side="top"
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
className="w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
|
||||
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-1rem)] overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
|
||||
>
|
||||
<div className="h-24 bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_55%,hsl(var(--muted))_100%)]" />
|
||||
<div className="-mt-8 px-4 pb-4">
|
||||
|
|
|
|||
|
|
@ -19,11 +19,19 @@ const mockOpenOnboarding = vi.hoisted(() => vi.fn());
|
|||
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
const mockLocation = vi.hoisted(() => ({ pathname: "/PAP/dashboard" }));
|
||||
const mockSidebarPreferencesApi = vi.hoisted(() => ({
|
||||
getCompanyOrder: vi.fn(),
|
||||
updateCompanyOrder: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sidebarPreferences", () => ({
|
||||
sidebarPreferencesApi: mockSidebarPreferencesApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
|
|
@ -112,6 +120,14 @@ describe("SidebarCompanyMenu", () => {
|
|||
},
|
||||
});
|
||||
mockAuthApi.signOut.mockResolvedValue(undefined);
|
||||
mockSidebarPreferencesApi.getCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ["company-1", "company-2", "company-3"],
|
||||
updatedAt: null,
|
||||
});
|
||||
mockSidebarPreferencesApi.updateCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ["company-1", "company-2", "company-3"],
|
||||
updatedAt: null,
|
||||
});
|
||||
mockLocation.pathname = "/PAP/dashboard";
|
||||
});
|
||||
|
||||
|
|
@ -149,6 +165,7 @@ describe("SidebarCompanyMenu", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Switch workspace");
|
||||
expect(document.body.textContent).toContain("Edit");
|
||||
expect(document.body.textContent).toContain("Strata");
|
||||
expect(document.body.textContent).toContain("ANA");
|
||||
expect(document.body.textContent).toContain("Add company...");
|
||||
|
|
@ -172,6 +189,62 @@ describe("SidebarCompanyMenu", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("toggles company order editing without selecting a workspace", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarCompanyMenu />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const editButton = Array.from(document.body.querySelectorAll("button"))
|
||||
.find((element) => element.textContent === "Edit");
|
||||
expect(editButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Done");
|
||||
expect(document.body.textContent).not.toContain("PAP");
|
||||
expect(document.body.textContent).not.toContain("ANA");
|
||||
expect(document.body.querySelector('button[aria-label="Reorder Strata"]')).toBeTruthy();
|
||||
|
||||
const strataItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Strata"));
|
||||
expect(strataItem).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
strataItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockSetSelectedCompanyId).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to the selected workspace dashboard from company-prefixed routes", async () => {
|
||||
mockLocation.pathname = "/PAP/issues";
|
||||
const root = createRoot(container);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,25 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ChevronsUpDown, LogOut, Plus, Settings, UserPlus } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
GripVertical,
|
||||
LogOut,
|
||||
Plus,
|
||||
Settings,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { authApi } from "@/api/auth";
|
||||
|
|
@ -15,6 +34,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useDialogActions } from "@/context/DialogContext";
|
||||
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
|
|
@ -36,8 +56,81 @@ function WorkspaceIcon({ company }: { company: Company }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SortableCompanyItem({
|
||||
company,
|
||||
isEditing,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
company: Company;
|
||||
isEditing: boolean;
|
||||
isSelected: boolean;
|
||||
onSelect: (company: Company) => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setActivatorNodeRef,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: company.id, disabled: !isEditing });
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
}}
|
||||
onSelect={(event) => {
|
||||
if (isEditing) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onSelect(company);
|
||||
}}
|
||||
className={cn(
|
||||
"min-w-0 gap-2 py-2",
|
||||
isEditing && "cursor-grab",
|
||||
isDragging && "opacity-80",
|
||||
isSelected && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<WorkspaceIcon company={company} />
|
||||
<span className="min-w-0 flex-1 truncate">{company.name}</span>
|
||||
{isEditing ? (
|
||||
<button
|
||||
type="button"
|
||||
ref={setActivatorNodeRef}
|
||||
aria-label={`Reorder ${company.name}`}
|
||||
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{company.issuePrefix}
|
||||
</span>
|
||||
{isSelected ? <Check className="size-4 text-muted-foreground" /> : null}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isEditingOrder, setIsEditingOrder] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
|
|
@ -46,6 +139,14 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
const navigate = useNavigate();
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = onOpenChange ?? setInternalOpen;
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 180, tolerance: 6 },
|
||||
}),
|
||||
);
|
||||
const sidebarCompanies = useMemo(
|
||||
() => companies.filter((company) => company.status !== "archived"),
|
||||
[companies],
|
||||
|
|
@ -55,6 +156,11 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const { orderedCompanies, persistOrder } = useCompanyOrder({
|
||||
companies: sidebarCompanies,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
const signOutMutation = useMutation({
|
||||
mutationFn: () => authApi.signOut(),
|
||||
|
|
@ -65,8 +171,14 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
},
|
||||
});
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) setIsEditingOrder(false);
|
||||
setOpen(nextOpen);
|
||||
}
|
||||
|
||||
function closeNavigationChrome() {
|
||||
setOpen(false);
|
||||
setIsEditingOrder(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}
|
||||
|
||||
|
|
@ -92,8 +204,23 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
openOnboarding();
|
||||
}
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const ids = orderedCompanies.map((company) => company.id);
|
||||
const oldIndex = ids.indexOf(active.id as string);
|
||||
const newIndex = ids.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
persistOrder(arrayMove(ids, oldIndex, newIndex));
|
||||
},
|
||||
[orderedCompanies, persistOrder],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -110,50 +237,85 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-64 p-1">
|
||||
<DropdownMenuLabel className="px-2 py-1.5 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
Switch workspace
|
||||
</DropdownMenuLabel>
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<DropdownMenuLabel className="p-0 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
Switch workspace
|
||||
</DropdownMenuLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditingOrder((current) => !current);
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{isEditingOrder ? "Done" : "Edit"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{sidebarCompanies.map((company) => {
|
||||
const isSelected = company.id === selectedCompany?.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={company.id}
|
||||
onClick={() => selectCompany(company)}
|
||||
className={cn(
|
||||
"min-w-0 gap-2 py-2",
|
||||
isSelected && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<WorkspaceIcon company={company} />
|
||||
<span className="min-w-0 flex-1 truncate">{company.name}</span>
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{company.issuePrefix}
|
||||
</span>
|
||||
{isSelected ? <Check className="size-4 text-muted-foreground" /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{sidebarCompanies.length === 0 ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedCompanies.map((company) => company.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{orderedCompanies.map((company) => (
|
||||
<SortableCompanyItem
|
||||
key={company.id}
|
||||
company={company}
|
||||
isEditing={isEditingOrder}
|
||||
isSelected={company.id === selectedCompany?.id}
|
||||
onSelect={selectCompany}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{orderedCompanies.length === 0 ? (
|
||||
<DropdownMenuItem disabled>No workspaces</DropdownMenuItem>
|
||||
) : null}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={addCompany} className="gap-2 py-2 text-muted-foreground">
|
||||
<DropdownMenuItem
|
||||
onClick={addCompany}
|
||||
className="gap-2 py-2 text-muted-foreground"
|
||||
disabled={isEditingOrder}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
<span>Add company...</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/company/settings/invites" onClick={closeNavigationChrome}>
|
||||
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
||||
<Link
|
||||
to="/company/settings/invites"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
<span className="truncate">
|
||||
{selectedCompany ? `Invite people to ${selectedCompany.name}` : "Invite people"}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/company/settings" onClick={closeNavigationChrome}>
|
||||
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
||||
<Link
|
||||
to="/company/settings"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
<span>Company settings</span>
|
||||
</Link>
|
||||
|
|
@ -164,7 +326,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => signOutMutation.mutate()}
|
||||
disabled={signOutMutation.isPending}
|
||||
disabled={isEditingOrder || signOutMutation.isPending}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
<span>{signOutMutation.isPending ? "Signing out..." : "Sign out"}</span>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
} from "lucide-react";
|
||||
import { BreadcrumbBar } from "@/components/BreadcrumbBar";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
import { CompanyRail } from "@/components/CompanyRail";
|
||||
import { CompanySwitcher } from "@/components/CompanySwitcher";
|
||||
import { KeyboardShortcutsCheatsheetContent } from "@/components/KeyboardShortcutsCheatsheet";
|
||||
import { MobileBottomNav } from "@/components/MobileBottomNav";
|
||||
|
|
@ -75,7 +74,6 @@ function SidebarShell({ collapsed = false }: { collapsed?: boolean }) {
|
|||
return (
|
||||
<div className="h-[520px] overflow-hidden border border-border bg-background">
|
||||
<div className="flex h-full min-h-0">
|
||||
<CompanyRail />
|
||||
<div className={cn("overflow-hidden transition-[width]", collapsed ? "w-0" : "w-60")}>
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
|
@ -249,12 +247,6 @@ function NavigationLayoutStories() {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Company rail" title="Multi-company rail with selected, inactive, live, and unread indicators">
|
||||
<div className="h-[420px] w-[72px] overflow-hidden border border-border bg-background">
|
||||
<CompanyRail />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Menus" title="Account, company, and switcher menus in open state">
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
<div className="relative h-[440px] overflow-hidden border border-border bg-background">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue