mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-24 04:14:18 +00:00
refactor(ui): remove the skill launcher flag and keep the chip rail (#13097)
This commit is contained in:
1 parent
76cde39b08
commit
4d922da2a6
12 files changed
+22
-477
No files matched your search
@@ -1,90 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LighthouseSkillsBlock } from "./lighthouse-skills-block";
|
||||
|
||||
describe("LighthouseSkillsBlock", () => {
|
||||
it("should render every skill with its description", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<LighthouseSkillsBlock onLaunchSkill={vi.fn()} onAskAnything={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Lighthouse AI Skills")).toBeInTheDocument();
|
||||
expect(screen.getByText("Contextual Fix")).toBeInTheDocument();
|
||||
expect(screen.getByText("Triage Decision")).toBeInTheDocument();
|
||||
expect(screen.getByText("Systemic Scope")).toBeInTheDocument();
|
||||
expect(screen.getByText("Compliance Impact")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Is this real, and if not, close it out"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should launch the clicked skill", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onLaunchSkill = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsBlock
|
||||
onLaunchSkill={onLaunchSkill}
|
||||
onAskAnything={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /Triage Decision/ }));
|
||||
|
||||
// Then
|
||||
expect(onLaunchSkill).toHaveBeenCalledOnce();
|
||||
expect(onLaunchSkill.mock.calls[0][0]).toMatchObject({
|
||||
id: "triage-decision",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render disabled skills as coming soon and not launch them", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onLaunchSkill = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsBlock
|
||||
onLaunchSkill={onLaunchSkill}
|
||||
onAskAnything={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const card = screen.getByRole("button", { name: /Compliance Impact/ });
|
||||
expect(card).toBeDisabled();
|
||||
expect(screen.getByText("Coming soon")).toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.click(card);
|
||||
|
||||
// Then
|
||||
expect(onLaunchSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should keep the free-form fallback available", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onAskAnything = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsBlock
|
||||
onLaunchSkill={vi.fn()}
|
||||
onAskAnything={onAskAnything}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: /ask Lighthouse anything about this finding/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(onAskAnything).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { getAllSkills } from "@/lib/lighthouse/skills/registry";
|
||||
import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills";
|
||||
|
||||
interface LighthouseSkillsBlockProps {
|
||||
onLaunchSkill: (skill: LighthouseSkillDefinition) => void;
|
||||
onAskAnything: () => void;
|
||||
}
|
||||
|
||||
// Finding-detail CTA: a grid of launchable agentic workflows plus
|
||||
// the free-form "ask anything" fallback that the old gradient banner offered.
|
||||
export function LighthouseSkillsBlock({
|
||||
onLaunchSkill,
|
||||
onAskAnything,
|
||||
}: LighthouseSkillsBlockProps) {
|
||||
return (
|
||||
<Card variant="lighthouse" className="gap-3 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<LighthouseIcon size={16} />
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Lighthouse AI Skills
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary ml-auto text-xs">
|
||||
Run in chat
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 @md:grid-cols-2">
|
||||
{getAllSkills().map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onLaunch={() => onLaunchSkill(skill)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAskAnything}
|
||||
className="text-text-lighthouse self-end text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
Or ask Lighthouse anything about this finding →
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCard({
|
||||
skill,
|
||||
onLaunch,
|
||||
}: {
|
||||
skill: LighthouseSkillDefinition;
|
||||
onLaunch: () => void;
|
||||
}) {
|
||||
const Icon = skill.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLaunch}
|
||||
disabled={!skill.enabled}
|
||||
className="group border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary disabled:hover:bg-bg-neutral-secondary flex items-start gap-2.5 rounded-lg border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Icon
|
||||
className="text-text-lighthouse mt-0.5 size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-text-neutral-primary flex items-center gap-1.5 text-sm font-medium">
|
||||
{skill.name}
|
||||
{skill.enabled ? (
|
||||
<ArrowRight
|
||||
className="text-text-lighthouse size-3.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span className="text-text-neutral-tertiary text-xs font-normal">
|
||||
Coming soon
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs leading-snug">
|
||||
{skill.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -24,9 +24,9 @@ interface LighthouseSkillsRailProps {
|
||||
onSubmitPrompt: (text: string) => void;
|
||||
}
|
||||
|
||||
// Finding-detail skill launcher, "dropdown" experiment variant (chip rail):
|
||||
// one compact row under the finding title — direct-launch chips plus a +N
|
||||
// trigger opening the same skills menu used by the table row surfaces.
|
||||
// Finding-detail skill launcher (chip rail): one compact row under the
|
||||
// finding title — direct-launch chips plus a +N trigger opening the same
|
||||
// skills menu used by the table row surfaces.
|
||||
export function LighthouseSkillsRail({
|
||||
onLaunchSkill,
|
||||
onSubmitPrompt,
|
||||
|
||||
+7
-106
@@ -28,7 +28,6 @@ const {
|
||||
mockRequestPanelChatMessage,
|
||||
mockRequestPanelSkillLaunch,
|
||||
mockIsCloud,
|
||||
mockUseSkillLauncherVariant,
|
||||
mockCurrentLighthouseContext,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetComplianceIcon: vi.fn((_: string) => null as string | null),
|
||||
@@ -43,7 +42,6 @@ const {
|
||||
mockRequestPanelChatMessage: vi.fn(),
|
||||
mockRequestPanelSkillLaunch: vi.fn(),
|
||||
mockIsCloud: vi.fn(() => true),
|
||||
mockUseSkillLauncherVariant: vi.fn(() => "card"),
|
||||
mockCurrentLighthouseContext: {
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
@@ -310,10 +308,6 @@ vi.mock("@/components/icons", () => ({
|
||||
LighthouseIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./use-skill-launcher-variant", () => ({
|
||||
useSkillLauncherVariant: mockUseSkillLauncherVariant,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/services/IconServices", () => ({
|
||||
JiraIcon: () => null,
|
||||
}));
|
||||
@@ -571,7 +565,6 @@ afterEach(() => {
|
||||
(_: string) => null as string | null,
|
||||
);
|
||||
mockIsCloud.mockReturnValue(true);
|
||||
mockUseSkillLauncherVariant.mockReturnValue("card");
|
||||
useSidePanelStore.setState({
|
||||
isOpen: false,
|
||||
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
|
||||
@@ -840,82 +833,7 @@ const mockResourceRow: FindingResourceRow = {
|
||||
lastSeenAt: null,
|
||||
};
|
||||
|
||||
describe("ResourceDetailDrawerContent — Lighthouse AI", () => {
|
||||
it("should open the Lighthouse tab without starting a conversation", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
useSidePanelStore.setState({
|
||||
isOpen: true,
|
||||
selectedTab: SIDE_PANEL_TAB.CONTEXT,
|
||||
});
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When: the free-form fallback only navigates to the chat tab
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: /ask Lighthouse anything about this finding/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then — no conversation is started on the user's behalf
|
||||
expect(mockRequestPanelChatMessage).not.toHaveBeenCalled();
|
||||
expect(useSidePanelStore.getState()).toMatchObject({
|
||||
isOpen: true,
|
||||
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
|
||||
});
|
||||
|
||||
// And launching a skill goes through the skill launcher with the context
|
||||
await user.click(screen.getByRole("button", { name: /Triage Decision/ }));
|
||||
expect(mockRequestPanelSkillLaunch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "triage-decision" }),
|
||||
mockCurrentLighthouseContext,
|
||||
);
|
||||
});
|
||||
|
||||
it("should hide the action when the Lighthouse panel tab is unavailable", () => {
|
||||
// Given
|
||||
mockIsCloud.mockReturnValue(false);
|
||||
|
||||
// When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByText("Lighthouse AI Skills")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: /ask Lighthouse anything about this finding/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — skill launcher experiment", () => {
|
||||
describe("ResourceDetailDrawerContent — Lighthouse skills rail", () => {
|
||||
const renderDrawer = (overrides: { isNavigating?: boolean } = {}) =>
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
@@ -932,15 +850,13 @@ describe("ResourceDetailDrawerContent — skill launcher experiment", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
it("should swap the footer card for the header chip rail on the dropdown variant", async () => {
|
||||
it("should launch a skill from the header chip rail", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
mockUseSkillLauncherVariant.mockReturnValue("dropdown");
|
||||
renderDrawer();
|
||||
|
||||
// Then — control card gone, rail chips present. The always-open dropdown
|
||||
// mock repeats every skill as a menu button, hence getAllByRole.
|
||||
expect(screen.queryByText("Lighthouse AI Skills")).not.toBeInTheDocument();
|
||||
// Then — rail chips present. The always-open dropdown mock repeats every
|
||||
// skill as a menu button, hence getAllByRole.
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Contextual Fix" }).length,
|
||||
).toBeGreaterThan(0);
|
||||
@@ -964,7 +880,6 @@ describe("ResourceDetailDrawerContent — skill launcher experiment", () => {
|
||||
it("should start a fresh conversation from the rail prompt", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
mockUseSkillLauncherVariant.mockReturnValue("dropdown");
|
||||
renderDrawer();
|
||||
|
||||
// When
|
||||
@@ -989,7 +904,6 @@ describe("ResourceDetailDrawerContent — skill launcher experiment", () => {
|
||||
|
||||
it("should hide the rail outside cloud and while navigating", () => {
|
||||
// Given
|
||||
mockUseSkillLauncherVariant.mockReturnValue("dropdown");
|
||||
mockIsCloud.mockReturnValue(false);
|
||||
|
||||
// When
|
||||
@@ -1012,21 +926,6 @@ describe("ResourceDetailDrawerContent — skill launcher experiment", () => {
|
||||
screen.queryByRole("button", { name: "Contextual Fix" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the card control for unresolved or unknown variants", () => {
|
||||
// Given — the hook already collapses those to "card"; the drawer treats
|
||||
// anything that is not exactly "dropdown" as control.
|
||||
mockUseSkillLauncherVariant.mockReturnValue("card");
|
||||
|
||||
// When
|
||||
renderDrawer();
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Lighthouse AI Skills")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "More Lighthouse skills" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — remediation code editors", () => {
|
||||
@@ -1914,7 +1813,9 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", ()
|
||||
expect(screen.getByText("security")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Status Extended:")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("uid-1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Lighthouse AI Skills")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "More Lighthouse skills" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the overview tab shell visible with section skeletons when navigating to a different check", () => {
|
||||
|
||||
+3
-29
@@ -91,10 +91,7 @@ import type {
|
||||
UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import {
|
||||
SKILL_LAUNCHER_VARIANT,
|
||||
type LighthouseSkillDefinition,
|
||||
} from "@/types/lighthouse-skills";
|
||||
import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills";
|
||||
|
||||
import { Muted } from "../../muted";
|
||||
import { DeltaIndicator } from "../delta-indicator";
|
||||
@@ -105,11 +102,9 @@ import {
|
||||
} from "../finding-triage-cells";
|
||||
import { DeltaValues, NotificationIndicator } from "../notification-indicator";
|
||||
|
||||
import { LighthouseSkillsBlock } from "./lighthouse-skills-block";
|
||||
import { LighthouseSkillsRail } from "./lighthouse-skills-rail";
|
||||
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
import { useSkillLauncherVariant } from "./use-skill-launcher-variant";
|
||||
|
||||
const OTHER_FINDINGS_ACTION_CELL_CLASS =
|
||||
"sticky right-0 z-20 min-w-12 last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary";
|
||||
@@ -322,10 +317,6 @@ export function ResourceDetailDrawerContent({
|
||||
const searchParams = useSearchParams();
|
||||
const openSidePanel = useSidePanelStore((state) => state.openPanel);
|
||||
const lighthouseContext = useLighthouseCurrentContext();
|
||||
// A/B experiment: PostHog decides between the footer card (control) and
|
||||
// the header chip rail. Falls back to the card until the flag resolves.
|
||||
const isDropdownLauncher =
|
||||
useSkillLauncherVariant() === SKILL_LAUNCHER_VARIANT.DROPDOWN;
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [optimisticallyMutedIds, setOptimisticallyMutedIds] = useState<
|
||||
Set<string>
|
||||
@@ -471,19 +462,11 @@ export function ResourceDetailDrawerContent({
|
||||
return result;
|
||||
};
|
||||
|
||||
// Navigation only: the panel picks up the focused finding as context on its
|
||||
// own, so no conversation is started on the user's behalf.
|
||||
const handleOpenLighthouseChat = () => {
|
||||
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
};
|
||||
|
||||
const handleLaunchSkill = (skill: LighthouseSkillDefinition) => {
|
||||
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
requestPanelSkillLaunch(skill, lighthouseContext.context);
|
||||
};
|
||||
|
||||
// TODO(experiment): capture launch origin + skill id via cloud's
|
||||
// trackEvent/ANALYTICS_EVENTS once the flag readout event lands.
|
||||
const handleSubmitPrompt = (text: string) => {
|
||||
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
requestPanelChatMessage(text, lighthouseContext.context);
|
||||
@@ -596,9 +579,8 @@ export function ResourceDetailDrawerContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skill launcher experiment, "dropdown" variant: chip rail under the
|
||||
title instead of the footer card. */}
|
||||
{isCloud() && !isNavigating && isDropdownLauncher && (
|
||||
{/* Lighthouse skill launcher: chip rail under the title. */}
|
||||
{isCloud() && !isNavigating && (
|
||||
<LighthouseSkillsRail
|
||||
onLaunchSkill={handleLaunchSkill}
|
||||
onSubmitPrompt={handleSubmitPrompt}
|
||||
@@ -1314,14 +1296,6 @@ export function ResourceDetailDrawerContent({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Lighthouse AI Skills (design 1d) — the experiment's card control */}
|
||||
{isCloud() && !isNavigating && !isDropdownLauncher && (
|
||||
<LighthouseSkillsBlock
|
||||
onLaunchSkill={handleLaunchSkill}
|
||||
onAskAnything={handleOpenLighthouseChat}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useFeatureFlagVariantMock } = vi.hoisted(() => ({
|
||||
useFeatureFlagVariantMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/feature-flags/client", () => ({
|
||||
useFeatureFlagVariant: useFeatureFlagVariantMock,
|
||||
}));
|
||||
|
||||
import { useSkillLauncherVariant } from "./use-skill-launcher-variant";
|
||||
|
||||
describe("useSkillLauncherVariant", () => {
|
||||
beforeEach(() => {
|
||||
useFeatureFlagVariantMock.mockReset();
|
||||
});
|
||||
|
||||
it("should read the skill launcher flag with card as the fallback", () => {
|
||||
useFeatureFlagVariantMock.mockReturnValue("card");
|
||||
|
||||
renderHook(() => useSkillLauncherVariant());
|
||||
|
||||
expect(useFeatureFlagVariantMock).toHaveBeenCalledWith(
|
||||
"finding-detail-skill-launcher",
|
||||
["card", "dropdown"],
|
||||
"card",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["dropdown", "dropdown"],
|
||||
["card", "card"],
|
||||
])(
|
||||
"should return the %s variant the flag module resolves",
|
||||
(_label, variant) => {
|
||||
useFeatureFlagVariantMock.mockReturnValue(variant);
|
||||
|
||||
const { result } = renderHook(() => useSkillLauncherVariant());
|
||||
|
||||
expect(result.current).toBe(variant);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FEATURE_FLAG, useFeatureFlagVariant } from "@/lib/feature-flags";
|
||||
import {
|
||||
SKILL_LAUNCHER_VARIANT,
|
||||
type SkillLauncherVariant,
|
||||
} from "@/types/lighthouse-skills";
|
||||
|
||||
// Unresolved or unknown variants (including OSS builds that never init
|
||||
// PostHog) fall back to the card control.
|
||||
export function useSkillLauncherVariant(): SkillLauncherVariant {
|
||||
return useFeatureFlagVariant(
|
||||
FEATURE_FLAG.SKILL_LAUNCHER,
|
||||
Object.values(SKILL_LAUNCHER_VARIANT),
|
||||
SKILL_LAUNCHER_VARIANT.CARD,
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,17 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useFeatureFlagEnabledMock, useFeatureFlagVariantKeyMock } = vi.hoisted(
|
||||
() => ({
|
||||
useFeatureFlagEnabledMock: vi.fn(),
|
||||
useFeatureFlagVariantKeyMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const { useFeatureFlagEnabledMock } = vi.hoisted(() => ({
|
||||
useFeatureFlagEnabledMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
useFeatureFlagEnabled: useFeatureFlagEnabledMock,
|
||||
useFeatureFlagVariantKey: useFeatureFlagVariantKeyMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
useFeatureFlag,
|
||||
useFeatureFlagState,
|
||||
useFeatureFlagVariant,
|
||||
} from "./client";
|
||||
import { useFeatureFlag, useFeatureFlagState } from "./client";
|
||||
import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "./shared";
|
||||
|
||||
const VARIANTS = ["card", "dropdown"] as const;
|
||||
|
||||
describe("useFeatureFlagState", () => {
|
||||
beforeEach(() => {
|
||||
useFeatureFlagEnabledMock.mockReset();
|
||||
@@ -62,19 +52,15 @@ describe("useFeatureFlag", () => {
|
||||
it("should pass the flag key through to PostHog", () => {
|
||||
useFeatureFlagEnabledMock.mockReturnValue(undefined);
|
||||
|
||||
renderHook(() => useFeatureFlag(FEATURE_FLAG.SKILL_LAUNCHER));
|
||||
renderHook(() => useFeatureFlag(FEATURE_FLAG.ISSUES));
|
||||
|
||||
expect(useFeatureFlagEnabledMock).toHaveBeenCalledWith(
|
||||
"finding-detail-skill-launcher",
|
||||
);
|
||||
expect(useFeatureFlagEnabledMock).toHaveBeenCalledWith("issues-feature");
|
||||
});
|
||||
|
||||
it("should return true when the flag is enabled", () => {
|
||||
useFeatureFlagEnabledMock.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFeatureFlag(FEATURE_FLAG.SKILL_LAUNCHER),
|
||||
);
|
||||
const { result } = renderHook(() => useFeatureFlag(FEATURE_FLAG.ISSUES));
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
@@ -85,52 +71,8 @@ describe("useFeatureFlag", () => {
|
||||
])("should return false when the flag is %s", (_label, flagValue) => {
|
||||
useFeatureFlagEnabledMock.mockReturnValue(flagValue);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFeatureFlag(FEATURE_FLAG.SKILL_LAUNCHER),
|
||||
);
|
||||
const { result } = renderHook(() => useFeatureFlag(FEATURE_FLAG.ISSUES));
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFeatureFlagVariant", () => {
|
||||
beforeEach(() => {
|
||||
useFeatureFlagVariantKeyMock.mockReset();
|
||||
});
|
||||
|
||||
it("should pass the flag key through to PostHog", () => {
|
||||
useFeatureFlagVariantKeyMock.mockReturnValue(undefined);
|
||||
|
||||
renderHook(() =>
|
||||
useFeatureFlagVariant(FEATURE_FLAG.SKILL_LAUNCHER, VARIANTS, "card"),
|
||||
);
|
||||
|
||||
expect(useFeatureFlagVariantKeyMock).toHaveBeenCalledWith(
|
||||
"finding-detail-skill-launcher",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return the resolved key when it is a known variant", () => {
|
||||
useFeatureFlagVariantKeyMock.mockReturnValue("dropdown");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFeatureFlagVariant(FEATURE_FLAG.SKILL_LAUNCHER, VARIANTS, "card"),
|
||||
);
|
||||
|
||||
expect(result.current).toBe("dropdown");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown variant", "weird"],
|
||||
["unresolved", undefined],
|
||||
["a boolean value", true],
|
||||
])("should fall back when the flag resolves to %s", (_label, flagValue) => {
|
||||
useFeatureFlagVariantKeyMock.mockReturnValue(flagValue);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFeatureFlagVariant(FEATURE_FLAG.SKILL_LAUNCHER, VARIANTS, "card"),
|
||||
);
|
||||
|
||||
expect(result.current).toBe("card");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useFeatureFlagEnabled,
|
||||
useFeatureFlagVariantKey,
|
||||
} from "posthog-js/react";
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react";
|
||||
|
||||
import {
|
||||
FEATURE_FLAG_STATE,
|
||||
@@ -31,15 +28,3 @@ export function useFeatureFlagState(flag: FeatureFlag): FeatureFlagState {
|
||||
export function useFeatureFlag(flag: FeatureFlag): boolean {
|
||||
return useFeatureFlagState(flag) === FEATURE_FLAG_STATE.ON;
|
||||
}
|
||||
|
||||
// Multivariate flag. Returns the resolved key when it is one of `variants`;
|
||||
// unresolved, boolean or unknown values fall back to `fallback`.
|
||||
export function useFeatureFlagVariant<T extends string>(
|
||||
flag: FeatureFlag,
|
||||
variants: readonly T[],
|
||||
fallback: T,
|
||||
): T {
|
||||
const value = useFeatureFlagVariantKey(flag);
|
||||
const match = variants.find((variant) => variant === value);
|
||||
return match ?? fallback;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "./shared";
|
||||
describe("FEATURE_FLAG", () => {
|
||||
it("should map each flag to the key PostHog knows it by", () => {
|
||||
expect(FEATURE_FLAG).toEqual({
|
||||
SKILL_LAUNCHER: "finding-detail-skill-launcher",
|
||||
ISSUES: "issues-feature",
|
||||
BILLING_SYSTEM_METRONOME: "BILLING_SYSTEM_METRONOME",
|
||||
});
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
// lives here, including the ones only the cloud fork reads, so the registry
|
||||
// never diverges between repos.
|
||||
export const FEATURE_FLAG = {
|
||||
// Multivariate flag deciding which skill launcher the finding detail drawer
|
||||
// renders ("card" | "dropdown").
|
||||
SKILL_LAUNCHER: "finding-detail-skill-launcher",
|
||||
// Boolean flag gating the experimental Issues feature (contextual findings).
|
||||
// Read by cloud only.
|
||||
ISSUES: "issues-feature",
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
// Variants of the FEATURE_FLAG.SKILL_LAUNCHER experiment (see
|
||||
// lib/feature-flags). Anything other than "dropdown" renders the card control.
|
||||
export const SKILL_LAUNCHER_VARIANT = {
|
||||
CARD: "card",
|
||||
DROPDOWN: "dropdown",
|
||||
} as const;
|
||||
|
||||
export type SkillLauncherVariant =
|
||||
(typeof SKILL_LAUNCHER_VARIANT)[keyof typeof SKILL_LAUNCHER_VARIANT];
|
||||
|
||||
export const LIGHTHOUSE_SKILL_ID = {
|
||||
CONTEXTUAL_FIX: "contextual-fix",
|
||||
TRIAGE_DECISION: "triage-decision",
|
||||
|
||||
Reference in new issue
Block a user