diff --git a/src/components/shared/Settings/resolveVisibleFlags.test.ts b/src/components/shared/Settings/resolveVisibleFlags.test.ts new file mode 100644 index 0000000000..f82249f5d7 --- /dev/null +++ b/src/components/shared/Settings/resolveVisibleFlags.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import type { Flag } from "@/types/configuration"; + +import { resolveVisibleFlags } from "./resolveVisibleFlags"; + +const createFlag = (flag: Partial & { key: string }): Flag => ({ + name: flag.key, + description: `${flag.key} description`, + default: false, + enabled: false, + category: "beta", + ...flag, +}); + +const keysOf = (flags: Flag[]) => flags.map((flag) => flag.key); + +describe("resolveVisibleFlags", () => { + it("keeps flags without a dependency", () => { + const flags = [createFlag({ key: "a" }), createFlag({ key: "b" })]; + + expect(keysOf(resolveVisibleFlags(flags))).toEqual(["a", "b"]); + }); + + it("keeps a dependent flag when its dependency is enabled", () => { + const flags = [ + createFlag({ key: "parent", enabled: true }), + createFlag({ key: "child", dependsOn: "parent" }), + ]; + + expect(keysOf(resolveVisibleFlags(flags))).toEqual(["parent", "child"]); + }); + + it("drops a dependent flag when its dependency is disabled", () => { + const flags = [ + createFlag({ key: "parent", enabled: false }), + createFlag({ key: "child", dependsOn: "parent" }), + ]; + + expect(keysOf(resolveVisibleFlags(flags))).toEqual(["parent"]); + }); + + it("drops a dependent flag even when it is itself enabled", () => { + const flags = [ + createFlag({ key: "parent", enabled: false }), + createFlag({ key: "child", enabled: true, dependsOn: "parent" }), + ]; + + expect(keysOf(resolveVisibleFlags(flags))).toEqual(["parent"]); + }); + + it("resolves the full dependency chain", () => { + const flags = [ + createFlag({ key: "grandparent", enabled: false }), + createFlag({ key: "parent", enabled: true, dependsOn: "grandparent" }), + createFlag({ key: "child", enabled: true, dependsOn: "parent" }), + ]; + + expect(keysOf(resolveVisibleFlags(flags))).toEqual(["grandparent"]); + }); + + it("drops a flag depending on an unknown key", () => { + const flags = [createFlag({ key: "child", dependsOn: "missing" })]; + + expect(resolveVisibleFlags(flags)).toEqual([]); + }); + + it("drops flags in a dependency cycle", () => { + const flags = [ + createFlag({ key: "a", enabled: true, dependsOn: "b" }), + createFlag({ key: "b", enabled: true, dependsOn: "a" }), + ]; + + expect(resolveVisibleFlags(flags)).toEqual([]); + }); + + it("filters settings and beta flags alike", () => { + const flags = [ + createFlag({ key: "parent", enabled: false }), + createFlag({ + key: "child-setting", + category: "setting", + dependsOn: "parent", + }), + createFlag({ key: "child-beta", dependsOn: "parent" }), + ]; + + expect(keysOf(resolveVisibleFlags(flags))).toEqual(["parent"]); + }); +}); diff --git a/src/components/shared/Settings/resolveVisibleFlags.ts b/src/components/shared/Settings/resolveVisibleFlags.ts new file mode 100644 index 0000000000..14ad166d91 --- /dev/null +++ b/src/components/shared/Settings/resolveVisibleFlags.ts @@ -0,0 +1,31 @@ +import type { Flag } from "@/types/configuration"; + +/** + * Drops flags whose `dependsOn` chain is not fully enabled, so a dependent flag + * never renders as a toggle that controls an unreachable feature. + * + * Fails closed: a chain pointing at a missing flag, or one that cycles, hides + * the dependent flag rather than exposing a control nothing can satisfy. + */ +export function resolveVisibleFlags(flags: Flag[]): Flag[] { + const flagsByKey = new Map(flags.map((flag) => [flag.key, flag])); + + const hasSatisfiedDependencies = (flag: Flag) => { + const visited = new Set([flag.key]); + let dependencyKey = flag.dependsOn; + + while (dependencyKey) { + if (visited.has(dependencyKey)) return false; + visited.add(dependencyKey); + + const dependency = flagsByKey.get(dependencyKey); + if (!dependency?.enabled) return false; + + dependencyKey = dependency.dependsOn; + } + + return true; + }; + + return flags.filter(hasSatisfiedDependencies); +} diff --git a/src/flags.ts b/src/flags.ts index 3f1d7749ff..c698684306 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -68,14 +68,15 @@ export const ExistingFlags: ConfigFlags = { description: "Automatically generate an AI description when viewing a component in component search.", default: false, - category: "beta", + category: "setting", + dependsOn: "component-search-v2", }, ["compare-runs"]: { name: "Compare runs", description: "Select two runs to compare their pipeline structure and results side by side.", - default: false, + default: true, category: "beta", }, @@ -83,7 +84,7 @@ export const ExistingFlags: ConfigFlags = { name: "Conditional task execution", description: 'Adds a "Conditional execution" setting to the task Config tab. A conditional task gains a "Run when" port that decides whether it runs, either from a literal or from an upstream value.', - default: false, + default: true, category: "beta", }, }; diff --git a/src/routes/Settings/SettingsFlagsContext.tsx b/src/routes/Settings/SettingsFlagsContext.tsx index 84405259f0..765eb41ef8 100644 --- a/src/routes/Settings/SettingsFlagsContext.tsx +++ b/src/routes/Settings/SettingsFlagsContext.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; +import { resolveVisibleFlags } from "@/components/shared/Settings/resolveVisibleFlags"; import { useFlagsReducer } from "@/components/shared/Settings/useFlagsReducer"; import { ExistingFlags } from "@/flags"; import { @@ -51,12 +52,9 @@ export function SettingsFlagsProvider({ children }: { children: ReactNode }) { } }; - const betaFlags = Object.values(flags).filter( - (flag) => flag.category === "beta", - ); - const settings = Object.values(flags).filter( - (flag) => flag.category === "setting", - ); + const visibleFlags = resolveVisibleFlags(flags); + const betaFlags = visibleFlags.filter((flag) => flag.category === "beta"); + const settings = visibleFlags.filter((flag) => flag.category === "setting"); return ( diff --git a/src/routes/Settings/sections/BetaFeaturesSettings.test.tsx b/src/routes/Settings/sections/BetaFeaturesSettings.test.tsx index 235367c5fb..d51b3a18d6 100644 --- a/src/routes/Settings/sections/BetaFeaturesSettings.test.tsx +++ b/src/routes/Settings/sections/BetaFeaturesSettings.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Flag } from "@/types/configuration"; @@ -34,15 +34,6 @@ const componentSearchFlag: Flag = { category: "beta", }; -const aiDescriptionFlag: Flag = { - key: "component-search-v2-ai-descriptions", - name: "Auto-generate component search AI descriptions", - description: "Automatically generate AI descriptions.", - default: false, - enabled: false, - category: "beta", -}; - describe("BetaFeaturesSettings", () => { beforeEach(() => { mocks.betaFlags = []; @@ -50,28 +41,28 @@ describe("BetaFeaturesSettings", () => { mocks.track.mockClear(); }); - it("hides the AI descriptions flag when component search is disabled", () => { - mocks.betaFlags = [componentSearchFlag, aiDescriptionFlag]; + it("renders the beta flags it is given", () => { + mocks.betaFlags = [componentSearchFlag]; render(); expect(screen.getByText("Component Search")).toBeInTheDocument(); - expect( - screen.queryByText("Auto-generate component search AI descriptions"), - ).not.toBeInTheDocument(); }); - it("shows the AI descriptions flag when component search is enabled", () => { - mocks.betaFlags = [ - { ...componentSearchFlag, enabled: true }, - aiDescriptionFlag, - ]; + it("tracks and forwards a toggle change", () => { + mocks.betaFlags = [componentSearchFlag]; render(); + fireEvent.click(screen.getByTestId("component-search-v2-switch")); - expect(screen.getByText("Component Search")).toBeInTheDocument(); - expect( - screen.getByText("Auto-generate component search AI descriptions"), - ).toBeInTheDocument(); + expect(mocks.track).toHaveBeenCalledWith("settings.toggle_changed", { + section: "beta_features", + flag_name: "component-search-v2", + new_value: true, + }); + expect(mocks.handleSetFlag).toHaveBeenCalledWith( + "component-search-v2", + true, + ); }); }); diff --git a/src/routes/Settings/sections/BetaFeaturesSettings.tsx b/src/routes/Settings/sections/BetaFeaturesSettings.tsx index f39e4cb686..f4bfc841f0 100644 --- a/src/routes/Settings/sections/BetaFeaturesSettings.tsx +++ b/src/routes/Settings/sections/BetaFeaturesSettings.tsx @@ -6,15 +6,6 @@ import { useSettingsFlags } from "../SettingsFlagsContext"; export function BetaFeaturesSettings() { const { betaFlags, handleSetFlag } = useSettingsFlags(); const { track } = useAnalytics(); - const componentSearchV2Enabled = betaFlags.some( - (flag) => flag.key === "component-search-v2" && flag.enabled, - ); - const componentSearchChildFlags = new Set([ - "component-search-v2-ai-descriptions", - ]); - const visibleBetaFlags = componentSearchV2Enabled - ? betaFlags - : betaFlags.filter((flag) => !componentSearchChildFlags.has(flag.key)); const handleChange = (key: string, enabled: boolean) => { track("settings.toggle_changed", { @@ -25,5 +16,5 @@ export function BetaFeaturesSettings() { handleSetFlag(key, enabled); }; - return ; + return ; } diff --git a/src/types/configuration.ts b/src/types/configuration.ts index af2cb5678e..592e44ec36 100644 --- a/src/types/configuration.ts +++ b/src/types/configuration.ts @@ -3,6 +3,7 @@ interface ConfigFlag { description: string; default: boolean; category: "beta" | "setting"; + dependsOn?: string; } export type ConfigFlags = Record;