mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(targets): gate CoachDrawer on /targets behind the Coach flag
/targets carries two Coach affordances — the per-card 'Ask Coach' CTA on every TargetCard and the page-level CoachDrawer mount. The CTAs were gated through `aiEnabled` (provider chain status), but the drawer mount always rendered regardless of the operator's global Coach flag. When the operator turned Coach off in Settings the drawer still loaded its SSE machinery + Sheet portal subtree even though no on-page CTA could open it. Read flags.coach via useFeatureFlags and fold it into the existing `aiEnabled` gate so the per-card CTAs vanish when either layer is off. Wrap the page-level <CoachDrawer> mount in the same gate as defence-in-depth, since the CTAs already hide when the flag is off. Tests: add two new cases to targets-coach-mount.test.tsx covering the Coach-flag-off branch (CTAs hide + drawer mount disappears). The sibling targets-* test files mock @tanstack/react-query without exposing QueryClientContext; add a use-feature-flags mock that short-circuits to the all-on default so the existing assertions keep running against the visible page shape.
This commit is contained in:
1 parent
b643baed82
commit
5a6f921928
5 files changed
+120
-14
No files matched your search
@@ -1,17 +1,18 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — Coach drawer mount + per-card CTA gate.
|
||||
*
|
||||
* Two scenarios:
|
||||
* Three scenarios:
|
||||
* 1. AI provider configured → per-card "Ask Coach" CTAs render
|
||||
* alongside each card; the drawer is mounted at the page level.
|
||||
* 2. AI provider NOT configured → CTAs are completely absent (no
|
||||
* broken-button state) but the drawer mount stays present (it's
|
||||
* controlled by the parent regardless; no harm in mounting it
|
||||
* collapsed, and it costs ~0 since closed Sheets render only the
|
||||
* portal root).
|
||||
* broken-button state).
|
||||
* 3. v1.4.37 W5 — operator's global Coach flag off → both the CTAs
|
||||
* and the page-level drawer mount vanish. The flag default is
|
||||
* all-on for fresh installs and on any fetch error, so the path
|
||||
* stays open by default (mocked via `useFeatureFlags` below).
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -19,6 +20,20 @@ vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/targets",
|
||||
}));
|
||||
|
||||
let coachFlag = true;
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => ({
|
||||
...actual.DEFAULT_ASSISTANT_FLAGS,
|
||||
coach: coachFlag,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const sampleData = {
|
||||
targets: [
|
||||
{
|
||||
@@ -110,6 +125,10 @@ function render() {
|
||||
}
|
||||
|
||||
describe("/targets page — Coach drawer mount + CTA gate", () => {
|
||||
beforeEach(() => {
|
||||
coachFlag = true;
|
||||
});
|
||||
|
||||
it("renders the per-card Coach CTA when an AI provider is configured", () => {
|
||||
providerChainResponse = { activeProvider: "openai" };
|
||||
const html = render();
|
||||
@@ -130,4 +149,27 @@ describe("/targets page — Coach drawer mount + CTA gate", () => {
|
||||
// configured we don't render a CTA whose click would 404.
|
||||
expect(html).not.toContain('data-slot="target-coach-cta"');
|
||||
});
|
||||
|
||||
// v1.4.37 W5 — Marc directive: when the operator turns off the
|
||||
// global Coach flag, every Coach affordance on /targets must vanish.
|
||||
// That is BOTH the per-card CTAs (currently gated through `aiEnabled`)
|
||||
// AND the page-level <CoachDrawer> mount — so the SSE machinery,
|
||||
// Sheet portal and source-chip thread never load.
|
||||
it("hides every per-card Coach CTA when the global Coach flag is off", () => {
|
||||
coachFlag = false;
|
||||
providerChainResponse = { activeProvider: "openai" };
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="target-coach-cta"');
|
||||
});
|
||||
|
||||
it("does not mount the CoachDrawer when the global Coach flag is off", () => {
|
||||
coachFlag = false;
|
||||
providerChainResponse = { activeProvider: "openai" };
|
||||
const html = render();
|
||||
// The drawer renders a Sheet that the body markup exposes via a
|
||||
// role + the i18n key it carries. We grep on the localised
|
||||
// composer label that the drawer always paints.
|
||||
expect(html).not.toContain('data-slot="coach-drawer"');
|
||||
expect(html).not.toContain('data-slot="coach-drawer-composer"');
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,22 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate
|
||||
// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext
|
||||
// from `@tanstack/react-query`, and this test mocks the library
|
||||
// without exposing that context. Mock the hook directly with the
|
||||
// all-on default so the i18n assertions below run against the visible
|
||||
// Coach surfaces.
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS,
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import TargetsPage from "../targets/page";
|
||||
|
||||
|
||||
@@ -98,6 +98,22 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate
|
||||
// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext
|
||||
// from `@tanstack/react-query`, and this test mocks the library
|
||||
// without exposing that context. Mock the hook directly so the page
|
||||
// runs with the all-on default (CTAs + drawer mount visible) the
|
||||
// responsive assertions below pin.
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS,
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import TargetsPage from "../targets/page";
|
||||
|
||||
|
||||
@@ -71,6 +71,22 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate
|
||||
// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext
|
||||
// from `@tanstack/react-query`, and this test mocks the library
|
||||
// without exposing that context. Mock the hook directly with the
|
||||
// all-on default so the spacing assertions below run against the
|
||||
// fully-painted page shape.
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS,
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import TargetsPage from "../targets/page";
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { TargetsSummaryHeader } from "@/components/targets/targets-summary-header";
|
||||
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
|
||||
import { useCoachHandoff } from "@/hooks/use-coach-handoff";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
|
||||
type TargetData = TargetCardData;
|
||||
|
||||
@@ -154,7 +155,15 @@ export default function TargetsPage() {
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
const aiEnabled = chainStatus?.activeProvider != null;
|
||||
// v1.4.37 W5 — operator's global Coach flag layered on top of the
|
||||
// per-user provider chain. Either gate disables every Coach
|
||||
// affordance on this page: the per-card CTAs (via `aiEnabled`
|
||||
// threaded into <TargetCard>) and the page-level <CoachDrawer> mount
|
||||
// below. The flag default is all-on for fresh installs and on any
|
||||
// fetch error, so the path stays open by default.
|
||||
const flags = useFeatureFlags();
|
||||
const coachEnabled = flags.coach;
|
||||
const aiEnabled = coachEnabled && chainStatus?.activeProvider != null;
|
||||
|
||||
// v1.4.25 W3e — Coach drawer state owned by the page. The per-card
|
||||
// CTA + summary header both feed the same drawer so the user only
|
||||
@@ -281,13 +290,20 @@ export default function TargetsPage() {
|
||||
|
||||
{/* v1.4.25 W3e — Coach drawer mounted at the page level. The
|
||||
drawer is fully-controlled; per-card CTAs flip `coachOpen`
|
||||
and seed `coachPrefill` via the `askCoach()` hook callback. */}
|
||||
<CoachDrawer
|
||||
open={coachOpen}
|
||||
onOpenChange={setCoachOpen}
|
||||
prefill={coachPrefill}
|
||||
key={`coach-drawer-${coachScope?.sources?.join(",") ?? "default"}`}
|
||||
/>
|
||||
and seed `coachPrefill` via the `askCoach()` hook callback.
|
||||
v1.4.37 W5 — short-circuit when the operator turned the
|
||||
global Coach flag off so the SSE machinery + Sheet portal
|
||||
never mount. The per-card CTAs already hide via `aiEnabled`
|
||||
above, so the drawer can never be triggered with the flag
|
||||
off; the gate is defence-in-depth. */}
|
||||
{coachEnabled && (
|
||||
<CoachDrawer
|
||||
open={coachOpen}
|
||||
onOpenChange={setCoachOpen}
|
||||
prefill={coachPrefill}
|
||||
key={`coach-drawer-${coachScope?.sources?.join(",") ?? "default"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user