PAP-90: gate Issues→Tasks noun on tasks-list surfaces (D1 option A)

Add a tiny useTaskNoun() helper returning { singular, plural } gated on the
enableStreamlinedLeftNavigation experimental flag: Task/Tasks when ON,
Issue/Issues when OFF (default). Apply it to the tasks-list page breadcrumb,
the issue-detail back-breadcrumb label, and the list-page New-Task button (via
createIssueLabel). Sidebar.tsx + MobileBottomNav.tsx labels are gated by the
sibling PAP-89 sidebar work in the same worktree; deeper copy stays renamed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Opus 4.8 committed 2026-06-05 17:41:58 -07:00
1 parent 854734a32f
commit c007fc19f0
3 files changed
+138 -4

No files matched your search

+96
View File
@@ -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<void>) {
let result: void | Promise<void> = 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(
<QueryClientProvider client={queryClient}>
<Harness />
</QueryClientProvider>,
);
});
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();
});
});
+35
View File
@@ -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;
}
+7 -4
View File
@@ -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}