mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
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:
1 parent
854734a32f
commit
c007fc19f0
3 files changed
+138
-4
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user