diff --git a/ui/src/lib/useTaskNoun.test.tsx b/ui/src/lib/useTaskNoun.test.tsx new file mode 100644 index 00000000000..8057d1c0dad --- /dev/null +++ b/ui/src/lib/useTaskNoun.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useTaskNoun, type TaskNoun } from "./useTaskNoun"; + +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + +vi.mock("../api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +let captured: TaskNoun | null = null; + +function Harness() { + captured = useTaskNoun(); + return null; +} + +async function renderHook() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + return () => { + root.unmount(); + container.remove(); + }; +} + +describe("useTaskNoun", () => { + beforeEach(() => { + captured = null; + }); + + afterEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + it("returns the Issue noun when the streamlined-nav flag is OFF (default)", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableStreamlinedLeftNavigation: false, + }); + const cleanup = await renderHook(); + expect(captured).toEqual({ singular: "Issue", plural: "Issues" }); + cleanup(); + }); + + it("returns the Task noun when the streamlined-nav flag is ON", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableStreamlinedLeftNavigation: true, + }); + const cleanup = await renderHook(); + expect(captured).toEqual({ singular: "Task", plural: "Tasks" }); + cleanup(); + }); + + it("falls back to the Issue noun before settings have loaded", async () => { + // Never resolves — simulates the in-flight state on first render. + mockInstanceSettingsApi.getExperimental.mockReturnValue(new Promise(() => {})); + const cleanup = await renderHook(); + expect(captured).toEqual({ singular: "Issue", plural: "Issues" }); + cleanup(); + }); +}); diff --git a/ui/src/lib/useTaskNoun.ts b/ui/src/lib/useTaskNoun.ts new file mode 100644 index 00000000000..1cda416c686 --- /dev/null +++ b/ui/src/lib/useTaskNoun.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; +import { instanceSettingsApi } from "../api/instanceSettings"; +import { queryKeys } from "./queryKeys"; + +export interface TaskNoun { + /** "Task" when the streamlined-nav flag is ON, "Issue" when OFF. */ + singular: string; + /** "Tasks" when the streamlined-nav flag is ON, "Issues" when OFF. */ + plural: string; +} + +const ISSUE_NOUN: TaskNoun = { singular: "Issue", plural: "Issues" }; +const TASK_NOUN: TaskNoun = { singular: "Task", plural: "Tasks" }; + +/** + * Returns the user-facing noun for a unit of work, gated on the + * `enableStreamlinedLeftNavigation` experimental flag (PAP-80 D1 → option A). + * + * Flag ON → { singular: "Task", plural: "Tasks" } + * Flag OFF → { singular: "Issue", plural: "Issues" } (default) + * + * Intended only for the high-visibility surfaces (sidebar/mobile nav labels, + * the New-Task button, the tasks-list title/breadcrumb). Deeper copy stays + * on the already-renamed "Task" wording. + */ +export function useTaskNoun(): TaskNoun { + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + + return experimentalSettings?.enableStreamlinedLeftNavigation === true + ? TASK_NOUN + : ISSUE_NOUN; +} diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index d0be7d1d55b..4abe44460d5 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -10,6 +10,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { queryKeys } from "../lib/queryKeys"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; +import { useTaskNoun } from "../lib/useTaskNoun"; import { EmptyState } from "../components/EmptyState"; import { IssuesList } from "../components/IssuesList"; import { CircleDot } from "lucide-react"; @@ -58,6 +59,7 @@ export function buildIssuesSearchUrl(currentHref: string, search: string): strin export function Issues() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); + const taskNoun = useTaskNoun(); const location = useLocation(); const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); @@ -108,16 +110,16 @@ export function Issues() { const issueLinkState = useMemo( () => createIssueDetailLocationState( - "Tasks", + taskNoun.plural, `${location.pathname}${location.search}${location.hash}`, "issues", ), - [location.pathname, location.search, location.hash], + [taskNoun.plural, location.pathname, location.search, location.hash], ); useEffect(() => { - setBreadcrumbs([{ label: "Tasks" }]); - }, [setBreadcrumbs]); + setBreadcrumbs([{ label: taskNoun.plural }]); + }, [setBreadcrumbs, taskNoun.plural]); const issuePageSize = workspaceIdFilter ? WORKSPACE_FILTER_ISSUE_LIMIT : ISSUES_PAGE_SIZE; @@ -188,6 +190,7 @@ export function Issues() { projects={projects} liveIssueIds={liveIssueIds} viewStateKey="paperclip:issues-view" + createIssueLabel={taskNoun.singular} issueLinkState={issueLinkState} initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined} initialWorkspaces={initialWorkspaces.length > 0 ? initialWorkspaces : undefined}