Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/components/shared/Settings/resolveVisibleFlags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";

import type { Flag } from "@/types/configuration";

import { resolveVisibleFlags } from "./resolveVisibleFlags";

const createFlag = (flag: Partial<Flag> & { 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"]);
});
});
31 changes: 31 additions & 0 deletions src/components/shared/Settings/resolveVisibleFlags.ts
Original file line number Diff line number Diff line change
@@ -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);
}
7 changes: 4 additions & 3 deletions src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,23 @@ 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",
},

["conditional-execution"]: {
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",
},
};
10 changes: 4 additions & 6 deletions src/routes/Settings/SettingsFlagsContext.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 (
<SettingsFlagsContext value={{ betaFlags, settings, handleSetFlag }}>
Expand Down
39 changes: 15 additions & 24 deletions src/routes/Settings/sections/BetaFeaturesSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -34,44 +34,35 @@ 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 = [];
mocks.handleSetFlag.mockClear();
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(<BetaFeaturesSettings />);

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(<BetaFeaturesSettings />);
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,
);
});
});
11 changes: 1 addition & 10 deletions src/routes/Settings/sections/BetaFeaturesSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand All @@ -25,5 +16,5 @@ export function BetaFeaturesSettings() {
handleSetFlag(key, enabled);
};

return <BetaFeatures betaFlags={visibleBetaFlags} onChange={handleChange} />;
return <BetaFeatures betaFlags={betaFlags} onChange={handleChange} />;
}
1 change: 1 addition & 0 deletions src/types/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ interface ConfigFlag {
description: string;
default: boolean;
category: "beta" | "setting";
dependsOn?: string;
}

export type ConfigFlags = Record<string, ConfigFlag>;
Expand Down
Loading