feat(FN-2695): gate Todos view behind todoView experimental flag
- Add todoView to experimental feature settings and expose it in Settings modal and docs - Derive todosEnabled in app settings hook with regression tests for enabled/disabled behavior - Gate App routing so Todos only renders when the flag is enabled and redirects otherwise - Hide Todos entries in header overflow and mobile navigation when the flag is off
This commit is contained in:
@@ -666,6 +666,7 @@ Common built-in dashboard flags include:
|
||||
- `skillsView`
|
||||
- `nodesView`
|
||||
- `devServerView`
|
||||
- `todoView`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -341,6 +341,7 @@ function AppInner() {
|
||||
roadmapEnabled,
|
||||
memoryEnabled,
|
||||
devServerEnabled,
|
||||
todosEnabled,
|
||||
toggleAutoMerge,
|
||||
toggleGlobalPause,
|
||||
toggleEnginePause,
|
||||
@@ -370,7 +371,10 @@ function AppInner() {
|
||||
if ((taskView === "devserver" || taskView === "dev-server") && !devServerEnabled) {
|
||||
handleChangeTaskView("board");
|
||||
}
|
||||
}, [taskView, insightsEnabled, roadmapEnabled, experimentalFeatures, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled]);
|
||||
if (taskView === "todos" && !todosEnabled) {
|
||||
handleChangeTaskView("board");
|
||||
}
|
||||
}, [taskView, insightsEnabled, roadmapEnabled, experimentalFeatures, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, todosEnabled]);
|
||||
|
||||
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
|
||||
useEffect(() => {
|
||||
@@ -841,6 +845,7 @@ function AppInner() {
|
||||
memoryView: memoryEnabled,
|
||||
devServer: devServerEnabled,
|
||||
devServerView: devServerEnabled,
|
||||
todoView: todosEnabled,
|
||||
}}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && (
|
||||
@@ -925,6 +930,7 @@ function AppInner() {
|
||||
memoryView: memoryEnabled,
|
||||
devServer: devServerEnabled,
|
||||
devServerView: devServerEnabled,
|
||||
todoView: todosEnabled,
|
||||
}}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && (
|
||||
|
||||
@@ -217,7 +217,7 @@ export interface HeaderProps {
|
||||
/** Whether the current view is a remote node */
|
||||
isRemote?: boolean;
|
||||
/** Experimental feature flags controlling visibility of nav items. */
|
||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean };
|
||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean };
|
||||
}
|
||||
|
||||
export function Header({
|
||||
@@ -325,17 +325,15 @@ export function Header({
|
||||
}, [overflowScripts]);
|
||||
|
||||
const hasViewOverflowItems = useMemo(() => {
|
||||
const hasTodosView = Boolean(onChangeView);
|
||||
|
||||
return !!(
|
||||
hasTodosView ||
|
||||
experimentalFeatures?.todoView ||
|
||||
experimentalFeatures?.insights ||
|
||||
experimentalFeatures?.roadmap ||
|
||||
showSkillsTab ||
|
||||
experimentalFeatures?.memoryView ||
|
||||
experimentalFeatures?.devServerView
|
||||
);
|
||||
}, [experimentalFeatures, onChangeView, showSkillsTab]);
|
||||
}, [experimentalFeatures, showSkillsTab]);
|
||||
|
||||
const getEffectiveViewport = useCallback(() => {
|
||||
const vv = window.visualViewport;
|
||||
@@ -1109,7 +1107,7 @@ export function Header({
|
||||
<>
|
||||
<button
|
||||
ref={viewOverflowTriggerRef}
|
||||
className={`view-toggle-btn${["skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "todos"].includes(view) ? " active" : ""}`}
|
||||
className={`view-toggle-btn${["skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (experimentalFeatures?.todoView && view === "todos") ? " active" : ""}`}
|
||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||
title="More views"
|
||||
aria-label="More views"
|
||||
@@ -1197,18 +1195,20 @@ export function Header({
|
||||
<span className="visually-hidden" data-testid="view-toggle-dev-server" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "todos" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onChangeView("todos");
|
||||
setIsViewOverflowOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
data-testid="view-overflow-todos"
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
<span>Todos</span>
|
||||
</button>
|
||||
{experimentalFeatures?.todoView && (
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "todos" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onChangeView("todos");
|
||||
setIsViewOverflowOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
data-testid="view-overflow-todos"
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
<span>Todos</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -63,7 +63,7 @@ export interface MobileNavBarProps {
|
||||
/** Whether to show the skills tab */
|
||||
showSkillsTab?: boolean;
|
||||
/** Experimental feature flags controlling visibility of nav items. */
|
||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean };
|
||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean };
|
||||
}
|
||||
|
||||
function GitHubLogo({ size = 20 }: { size?: number }) {
|
||||
@@ -176,6 +176,7 @@ export function MobileNavBar({
|
||||
|
||||
const roadmapEnabled = Boolean(experimentalFeatures?.roadmap);
|
||||
const skillsEnabled = Boolean(showSkillsTab);
|
||||
const todoViewEnabled = Boolean(experimentalFeatures?.todoView);
|
||||
|
||||
// Keep a maximum of one optional primary tab visible at once to preserve touch-target width.
|
||||
// Overflowed destinations remain available in the More sheet.
|
||||
@@ -189,7 +190,7 @@ export function MobileNavBar({
|
||||
|| view === "memory"
|
||||
|| view === "devserver"
|
||||
|| view === "dev-server"
|
||||
|| view === "todos"
|
||||
|| (view === "todos" && todoViewEnabled)
|
||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||
|| (view === "skills" && !showSkillsTopLevel);
|
||||
|
||||
@@ -596,15 +597,17 @@ export function MobileNavBar({
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
data-testid="mobile-more-item-todos"
|
||||
onClick={() => handleMoreAction(() => onChangeView("todos"))}
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
<span>Todos</span>
|
||||
</button>
|
||||
{todoViewEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
data-testid="mobile-more-item-todos"
|
||||
onClick={() => handleMoreAction(() => onChangeView("todos"))}
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
<span>Todos</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="mobile-more-separator" />
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
|
||||
skillsView: "Skills View",
|
||||
nodesView: "Nodes View",
|
||||
devServerView: "Dev Server",
|
||||
todoView: "Todo List",
|
||||
};
|
||||
|
||||
const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record<string, string> = {
|
||||
|
||||
@@ -146,13 +146,13 @@ describe("Header", () => {
|
||||
expect(listBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders view overflow trigger so Todos remains reachable", () => {
|
||||
renderHeader({ onChangeView: noop });
|
||||
it("renders view overflow trigger when todoView experimental flag is enabled", () => {
|
||||
renderHeader({ onChangeView: noop, experimentalFeatures: { todoView: true } });
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows the Todos entry in view overflow", () => {
|
||||
renderHeader({ onChangeView: noop });
|
||||
it("shows the Todos entry in view overflow when todoView is enabled", () => {
|
||||
renderHeader({ onChangeView: noop, experimentalFeatures: { todoView: true } });
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
|
||||
});
|
||||
@@ -167,13 +167,13 @@ describe("Header", () => {
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("still renders view overflow trigger when overflow feature flags are explicitly false", () => {
|
||||
it("does not render view overflow trigger when all overflow feature flags are false", () => {
|
||||
renderHeader({
|
||||
onChangeView: noop,
|
||||
showSkillsTab: false,
|
||||
experimentalFeatures: { insights: false, roadmap: false, memoryView: false },
|
||||
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false, todoView: false },
|
||||
});
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||
expect(screen.queryByTestId("view-toggle-overflow-trigger")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -179,4 +179,24 @@ describe("useAppSettings", () => {
|
||||
expect(result.current.devServerEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("derives todosEnabled from experimentalFeatures.todoView", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
autoMerge: false,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
prAuthAvailable: true,
|
||||
taskStuckTimeoutMs: 600000,
|
||||
showQuickChatFAB: false,
|
||||
experimentalFeatures: {
|
||||
todoView: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const { result } = renderHook(() => useAppSettings("proj_123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.todosEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface UseAppSettingsResult {
|
||||
roadmapEnabled: boolean;
|
||||
memoryEnabled: boolean;
|
||||
devServerEnabled: boolean;
|
||||
todosEnabled: boolean;
|
||||
toggleAutoMerge: () => Promise<void>;
|
||||
toggleGlobalPause: () => Promise<void>;
|
||||
toggleEnginePause: () => Promise<void>;
|
||||
@@ -43,6 +44,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
const [roadmapEnabled, setRoadmapEnabled] = useState(false);
|
||||
const [memoryEnabled, setMemoryEnabled] = useState(false);
|
||||
const [devServerEnabled, setDevServerEnabled] = useState(false);
|
||||
const [todosEnabled, setTodosEnabled] = useState(false);
|
||||
|
||||
/**
|
||||
* Fetches config and settings from the backend and updates local state.
|
||||
@@ -73,6 +75,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
setRoadmapEnabled(features.roadmap === true);
|
||||
setMemoryEnabled(features.memoryView === true);
|
||||
setDevServerEnabled(features.devServerView === true || features.devServer === true);
|
||||
setTodosEnabled(features.todoView === true);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
@@ -144,6 +147,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
roadmapEnabled,
|
||||
memoryEnabled,
|
||||
devServerEnabled,
|
||||
todosEnabled,
|
||||
toggleAutoMerge,
|
||||
toggleGlobalPause,
|
||||
toggleEnginePause,
|
||||
|
||||
Reference in New Issue
Block a user