From e78d0b34ea1118c871f360b87f241747c68c767c Mon Sep 17 00:00:00 2001 From: James G Date: Fri, 27 Mar 2026 14:20:19 +0000 Subject: [PATCH 1/8] feat(toolbar): plumb archived and deprecated through flag types and state - Extend API/local flag shapes with optional deprecated metadata - Set archived and deprecated in FlagStateManager and SDK override provider --- packages/toolbar/src/core/services/FlagStateManager.ts | 2 ++ packages/toolbar/src/core/types/devServer.ts | 2 ++ .../src/core/ui/Toolbar/components/new/FeatureFlags/types.ts | 2 ++ .../src/core/ui/Toolbar/context/FlagSdkOverrideProvider.tsx | 4 ++++ packages/toolbar/src/core/ui/Toolbar/types/ldApi.ts | 2 ++ 5 files changed, 12 insertions(+) diff --git a/packages/toolbar/src/core/services/FlagStateManager.ts b/packages/toolbar/src/core/services/FlagStateManager.ts index 77165033..605fb7b0 100644 --- a/packages/toolbar/src/core/services/FlagStateManager.ts +++ b/packages/toolbar/src/core/services/FlagStateManager.ts @@ -48,6 +48,8 @@ export class FlagStateManager { type: this.determineFlagType(variations, currentValue), sourceEnvironment: devServerData.sourceEnvironmentKey, enabled: flagState.value !== null && flagState.value !== undefined, + archived: apiFlag?.archived ?? false, + deprecated: apiFlag?.deprecated ?? false, }; }); diff --git a/packages/toolbar/src/core/types/devServer.ts b/packages/toolbar/src/core/types/devServer.ts index 37032e4d..49a09403 100644 --- a/packages/toolbar/src/core/types/devServer.ts +++ b/packages/toolbar/src/core/types/devServer.ts @@ -11,6 +11,8 @@ export interface EnhancedFlag { type: 'boolean' | 'multivariate' | 'string' | 'number' | 'object'; sourceEnvironment: string; // e.g., "production", "test" enabled: boolean; // Whether flag is active + archived: boolean; + deprecated: boolean; } // Configuration Interface diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/types.ts b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/types.ts index 46f0fad9..7041250c 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/types.ts +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/types.ts @@ -7,4 +7,6 @@ export interface NormalizedFlag { type: 'boolean' | 'multivariate' | 'string' | 'number' | 'object'; currentValue: any; availableVariations: ApiVariation[]; + archived: boolean; + deprecated: boolean; } diff --git a/packages/toolbar/src/core/ui/Toolbar/context/FlagSdkOverrideProvider.tsx b/packages/toolbar/src/core/ui/Toolbar/context/FlagSdkOverrideProvider.tsx index 2f71c751..6d784e0a 100644 --- a/packages/toolbar/src/core/ui/Toolbar/context/FlagSdkOverrideProvider.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/context/FlagSdkOverrideProvider.tsx @@ -10,6 +10,8 @@ interface LocalFlag { isOverridden: boolean; type: 'boolean' | 'multivariate' | 'string' | 'number' | 'object'; availableVariations: ApiVariation[]; + archived: boolean; + deprecated: boolean; } interface FlagSdkOverrideContextType { @@ -84,6 +86,8 @@ export function FlagSdkOverrideProvider({ children, flagOverridePlugin }: FlagSd isOverridden: flagKey in overrides, type: determineFlagType(apiFlag?.variations || [], currentValue), availableVariations: apiFlag?.variations || [], + archived: apiFlag?.archived ?? false, + deprecated: apiFlag?.deprecated ?? false, }; }); diff --git a/packages/toolbar/src/core/ui/Toolbar/types/ldApi.ts b/packages/toolbar/src/core/ui/Toolbar/types/ldApi.ts index 4df7a95e..6a0eb7bd 100644 --- a/packages/toolbar/src/core/ui/Toolbar/types/ldApi.ts +++ b/packages/toolbar/src/core/ui/Toolbar/types/ldApi.ts @@ -13,6 +13,8 @@ export interface ApiEnvironment { export interface ApiFlag { archived: boolean; + /** Present on flags in LaunchDarkly API v2+ when marked deprecated */ + deprecated?: boolean; clientSideAvailability: { usingEnvironmentId: boolean; usingMobileKey: boolean; From 20898205d75a21809b35b106f1248ec5d4648c7a Mon Sep 17 00:00:00 2001 From: James G Date: Fri, 27 Mar 2026 14:20:27 +0000 Subject: [PATCH 2/8] feat(toolbar): persist includeDeprecated and includeArchived settings - Add toolbar settings keys with defaults in localStorage helpers - Expose lifecycle toggles and reset from ToolbarStateProvider --- .../context/state/ToolbarStateProvider.tsx | 44 ++++++++++++++++++ .../src/core/ui/Toolbar/utils/localStorage.ts | 46 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/packages/toolbar/src/core/ui/Toolbar/context/state/ToolbarStateProvider.tsx b/packages/toolbar/src/core/ui/Toolbar/context/state/ToolbarStateProvider.tsx index 18271f67..4ce9e50a 100644 --- a/packages/toolbar/src/core/ui/Toolbar/context/state/ToolbarStateProvider.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/context/state/ToolbarStateProvider.tsx @@ -21,6 +21,10 @@ import { loadToolbarAutoCollapse, loadReloadOnFlagChange, saveReloadOnFlagChange, + loadIncludeDeprecatedFlags, + saveIncludeDeprecatedFlags, + loadIncludeArchivedFlags, + saveIncludeArchivedFlags, } from '../../utils/localStorage'; export interface ToolbarStateContextValue { @@ -37,6 +41,8 @@ export interface ToolbarStateContextValue { isOptedInToAnalytics: boolean; isOptedInToEnhancedAnalytics: boolean; isOptedInToSessionReplay: boolean; + includeDeprecatedFlags: boolean; + includeArchivedFlags: boolean; // Refs toolbarRef: React.RefObject; @@ -48,6 +54,9 @@ export interface ToolbarStateContextValue { handleToggleReloadOnFlagChange: () => void; handleToggleAutoCollapse: () => void; handleCircleClick: () => void; + handleToggleIncludeDeprecatedFlags: () => void; + handleToggleIncludeArchivedFlags: () => void; + resetFlagLifecycleFilters: () => void; setIsAnimating: Dispatch>; setSearchIsExpanded: Dispatch>; handleToggleAnalyticsOptOut: (enabled: boolean) => void; @@ -76,6 +85,8 @@ export function ToolbarStateProvider({ children, domId, devServerUrl }: ToolbarS const [searchIsExpanded, setSearchIsExpanded] = useState(false); const [reloadOnFlagChangeIsEnabled, enableReloadOnFlagChange] = useState(() => loadReloadOnFlagChange()); const [isAutoCollapseEnabled, setAutoCollapse] = useState(() => loadToolbarAutoCollapse()); + const [includeDeprecatedFlags, setIncludeDeprecatedFlags] = useState(() => loadIncludeDeprecatedFlags()); + const [includeArchivedFlags, setIncludeArchivedFlags] = useState(() => loadIncludeArchivedFlags()); const [mode] = useState(() => getToolbarMode(devServerUrl)); // Refs @@ -154,6 +165,29 @@ export function ToolbarStateProvider({ children, domId, devServerUrl }: ToolbarS }); }, []); + const handleToggleIncludeDeprecatedFlags = useCallback(() => { + setIncludeDeprecatedFlags((prev) => { + const newValue = !prev; + saveIncludeDeprecatedFlags(newValue); + return newValue; + }); + }, []); + + const handleToggleIncludeArchivedFlags = useCallback(() => { + setIncludeArchivedFlags((prev) => { + const newValue = !prev; + saveIncludeArchivedFlags(newValue); + return newValue; + }); + }, []); + + const resetFlagLifecycleFilters = useCallback(() => { + saveIncludeDeprecatedFlags(false); + saveIncludeArchivedFlags(false); + setIncludeDeprecatedFlags(false); + setIncludeArchivedFlags(false); + }, []); + const handleCircleClick = useCallback(() => { if (!isExpanded) { setIsExpanded(true); @@ -207,6 +241,8 @@ export function ToolbarStateProvider({ children, domId, devServerUrl }: ToolbarS isOptedInToAnalytics: analyticsPreferences.isOptedInToAnalytics, isOptedInToEnhancedAnalytics: analyticsPreferences.isOptedInToEnhancedAnalytics, isOptedInToSessionReplay: analyticsPreferences.isOptedInToSessionReplay, + includeDeprecatedFlags, + includeArchivedFlags, // Refs toolbarRef, @@ -218,6 +254,9 @@ export function ToolbarStateProvider({ children, domId, devServerUrl }: ToolbarS handleToggleReloadOnFlagChange, handleToggleAutoCollapse, handleCircleClick, + handleToggleIncludeDeprecatedFlags, + handleToggleIncludeArchivedFlags, + resetFlagLifecycleFilters, setIsAnimating, setSearchIsExpanded, handleToggleAnalyticsOptOut: analyticsPreferences.handleToggleAnalyticsOptOut, @@ -232,6 +271,8 @@ export function ToolbarStateProvider({ children, domId, devServerUrl }: ToolbarS slideDirection, reloadOnFlagChangeIsEnabled, isAutoCollapseEnabled, + includeDeprecatedFlags, + includeArchivedFlags, mode, analyticsPreferences, handleTabChange, @@ -240,6 +281,9 @@ export function ToolbarStateProvider({ children, domId, devServerUrl }: ToolbarS handleToggleReloadOnFlagChange, handleToggleAutoCollapse, handleCircleClick, + handleToggleIncludeDeprecatedFlags, + handleToggleIncludeArchivedFlags, + resetFlagLifecycleFilters, ], ); diff --git a/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts b/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts index 4fd9b24b..83a20596 100644 --- a/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts +++ b/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts @@ -23,6 +23,10 @@ export interface ToolbarSettings { isOptedInToAnalytics: boolean; isOptedInToEnhancedAnalytics: boolean; isOptedInToSessionReplay: boolean; + /** When true, show deprecated flags in the flags list (default false = live-only) */ + includeDeprecatedFlags: boolean; + /** When true, show archived flags in the flags list (default false = live-only) */ + includeArchivedFlags: boolean; } export const DEFAULT_SETTINGS: ToolbarSettings = { @@ -33,6 +37,8 @@ export const DEFAULT_SETTINGS: ToolbarSettings = { isOptedInToAnalytics: false, isOptedInToEnhancedAnalytics: false, isOptedInToSessionReplay: false, + includeDeprecatedFlags: false, + includeArchivedFlags: false, }; /** @@ -117,6 +123,46 @@ export function loadReloadOnFlagChange(): boolean { } } +export function saveIncludeDeprecatedFlags(include: boolean): void { + updateSetting('includeDeprecatedFlags', include); +} + +export function loadIncludeDeprecatedFlags(): boolean { + try { + const stored = localStorage.getItem(TOOLBAR_STORAGE_KEYS.SETTINGS); + if (!stored) { + return DEFAULT_SETTINGS.includeDeprecatedFlags; + } + const parsed = JSON.parse(stored) as Partial; + return typeof parsed.includeDeprecatedFlags === 'boolean' + ? parsed.includeDeprecatedFlags + : DEFAULT_SETTINGS.includeDeprecatedFlags; + } catch (error) { + console.warn('Failed to load includeDeprecatedFlags from localStorage:', error); + return DEFAULT_SETTINGS.includeDeprecatedFlags; + } +} + +export function saveIncludeArchivedFlags(include: boolean): void { + updateSetting('includeArchivedFlags', include); +} + +export function loadIncludeArchivedFlags(): boolean { + try { + const stored = localStorage.getItem(TOOLBAR_STORAGE_KEYS.SETTINGS); + if (!stored) { + return DEFAULT_SETTINGS.includeArchivedFlags; + } + const parsed = JSON.parse(stored) as Partial; + return typeof parsed.includeArchivedFlags === 'boolean' + ? parsed.includeArchivedFlags + : DEFAULT_SETTINGS.includeArchivedFlags; + } catch (error) { + console.warn('Failed to load includeArchivedFlags from localStorage:', error); + return DEFAULT_SETTINGS.includeArchivedFlags; + } +} + export function loadStarredFlags(): Set { try { const stored = localStorage.getItem(TOOLBAR_STORAGE_KEYS.STARRED_FLAGS); From 969dfb8590bbfd28b1d3b062bb0c4fa161064465 Mon Sep 17 00:00:00 2001 From: James G Date: Fri, 27 Mar 2026 14:20:33 +0000 Subject: [PATCH 3/8] feat(toolbar): add passesFlagLifecycleFilter helper and unit tests - Treat only explicit boolean true as archived/deprecated for filtering --- .../core/tests/flagLifecycleFilter.test.ts | 39 +++++++++++++++++++ .../new/FeatureFlags/flagLifecycleFilter.ts | 21 ++++++++++ 2 files changed, 60 insertions(+) create mode 100644 packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts create mode 100644 packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts diff --git a/packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts b/packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts new file mode 100644 index 00000000..20514c09 --- /dev/null +++ b/packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { passesFlagLifecycleFilter } from '../ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter'; + +describe('passesFlagLifecycleFilter', () => { + const live = { archived: false, deprecated: false }; + const archivedOnly = { archived: true, deprecated: false }; + const deprecatedOnly = { archived: false, deprecated: true }; + const both = { archived: true, deprecated: true }; + + it('excludes archived and deprecated when both includes are false (default)', () => { + expect(passesFlagLifecycleFilter(live, false, false)).toBe(true); + expect(passesFlagLifecycleFilter(archivedOnly, false, false)).toBe(false); + expect(passesFlagLifecycleFilter(deprecatedOnly, false, false)).toBe(false); + expect(passesFlagLifecycleFilter(both, false, false)).toBe(false); + }); + + it('includes archived when includeArchived is true', () => { + expect(passesFlagLifecycleFilter(archivedOnly, false, true)).toBe(true); + expect(passesFlagLifecycleFilter(deprecatedOnly, false, true)).toBe(false); + }); + + it('includes deprecated when includeDeprecated is true', () => { + expect(passesFlagLifecycleFilter(deprecatedOnly, true, false)).toBe(true); + expect(passesFlagLifecycleFilter(archivedOnly, true, false)).toBe(false); + }); + + it('includes both when both toggles are true', () => { + expect(passesFlagLifecycleFilter(both, true, true)).toBe(true); + }); + + it('does not hide flags when archived/deprecated are loose non-booleans (only true excludes)', () => { + expect( + passesFlagLifecycleFilter({ archived: 'false' as unknown as boolean, deprecated: false }, false, false), + ).toBe(true); + expect(passesFlagLifecycleFilter({ archived: false, deprecated: 1 as unknown as boolean }, false, false)).toBe( + true, + ); + }); +}); diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts new file mode 100644 index 00000000..2cc70472 --- /dev/null +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts @@ -0,0 +1,21 @@ +import type { NormalizedFlag } from './types'; + +/** + * Returns whether a flag should appear given lifecycle preferences. + * Default (both false): show only live flags (not archived, not deprecated). + */ +export function passesFlagLifecycleFilter( + flag: Pick, + includeDeprecated: boolean, + includeArchived: boolean, +): boolean { + // Only treat explicit boolean `true` as lifecycle-hidden. Loose truthy values (e.g. some API + // payloads or corrupted local state) must not hide flags; SDK-only rows default to false. + if (!includeArchived && flag.archived === true) { + return false; + } + if (!includeDeprecated && flag.deprecated === true) { + return false; + } + return true; +} From cd54d93f2d7ec4fb6a98a35a81fcec193306a190 Mon Sep 17 00:00:00 2001 From: James G Date: Fri, 27 Mar 2026 14:20:39 +0000 Subject: [PATCH 4/8] feat(toolbar): apply lifecycle filter in flags list and header stats - Filter dev and SDK flag lists using toolbar lifecycle preferences - Align stats copy with in-scope lifecycle counts; avoid stale memo on toggle --- .../components/new/FeatureFlags/FlagList.tsx | 121 ++++++++++++++---- .../new/FeatureFlags/FlagListContent.tsx | 10 +- 2 files changed, 104 insertions(+), 27 deletions(-) diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx index f7144977..ee342809 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx @@ -17,12 +17,13 @@ import { NormalizedFlag } from './types'; import { EnhancedFlag } from '../../../../../types/devServer'; import { GenericHelpText } from '../../GenericHelpText'; import { VIRTUALIZATION } from '../../../constants'; +import { passesFlagLifecycleFilter } from './flagLifecycleFilter'; import * as styles from './FlagList.module.css.ts'; // Dev Server Mode Component function DevServerFlagList() { const { state, setOverride, clearOverride } = useDevServerContext(); - const { reloadOnFlagChangeIsEnabled } = useToolbarState(); + const { reloadOnFlagChangeIsEnabled, includeDeprecatedFlags, includeArchivedFlags } = useToolbarState(); const { searchTerms } = useTabSearchContext(); const searchTerm = useMemo(() => searchTerms['flags'] || '', [searchTerms]); const { activeFilters } = useSubtabFilters('flags'); @@ -44,9 +45,21 @@ function DevServerFlagList() { isOverridden: flag.isOverridden, type: flag.type, availableVariations: flag.availableVariations, + archived: flag.archived, + deprecated: flag.deprecated, })); }, [allFlags]); + const lifecycleEligibleCount = useMemo(() => { + let n = 0; + for (const flag of normalizedFlags) { + if (passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { + n++; + } + } + return n; + }, [normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + // Filter flags based on search term and active filters const filteredFlagIndices = useMemo(() => { const searchLower = searchTerm.toLowerCase(); @@ -84,8 +97,21 @@ function DevServerFlagList() { return result; }, [normalizedFlags, searchTerm, activeFilters, isStarred]); + const lifecycleFilteredIndices = useMemo(() => { + const out: number[] = []; + for (const index of filteredFlagIndices) { + const flag = normalizedFlags[index]; + if (!flag) continue; + if (!passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { + continue; + } + out.push(index); + } + return out; + }, [filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + const virtualizer = useVirtualizer({ - count: filteredFlagIndices.length, + count: lifecycleFilteredIndices.length, getScrollElement, estimateSize: () => VIRTUALIZATION.ITEM_HEIGHT + VIRTUALIZATION.GAP, overscan: VIRTUALIZATION.OVERSCAN, @@ -128,12 +154,11 @@ function DevServerFlagList() { [clearOverride, analytics, reloadOnFlagChangeIsEnabled], ); - // Calculate stats - const totalFlags = normalizedFlags.length; - const filteredCount = filteredFlagIndices.length; - const isFiltered = searchTerm || !activeFilters.has('all'); + // Calculate stats (denominator = flags in scope for current lifecycle toggles, not raw API count) + const filteredCount = lifecycleFilteredIndices.length; + const statsLabel = `${filteredCount} of ${lifecycleEligibleCount} flags`; - if (filteredFlagIndices.length === 0 && !searchTerm && activeFilters.has('all')) { + if (normalizedFlags.length === 0) { return (
- 0 of {totalFlags} flags + 0 of {lifecycleEligibleCount} flags
); } + if (lifecycleFilteredIndices.length === 0 && filteredFlagIndices.length > 0) { + return ( +
+
+ 0 of {lifecycleEligibleCount} flags +
+ +
+ ); + } + return (
- - {isFiltered ? `${filteredCount} of ${totalFlags} flags` : `${totalFlags} flags`} - + {statsLabel}
{virtualizer.getVirtualItems().map((virtualItem) => { - const flagIndex = filteredFlagIndices[virtualItem.index]; + const flagIndex = lifecycleFilteredIndices[virtualItem.index]; if (flagIndex === undefined) return null; const normalizedFlag = normalizedFlags[flagIndex]; @@ -213,7 +250,7 @@ function DevServerFlagList() { // SDK Mode Component function SdkFlagList() { const { flags, setOverride, removeOverride } = useFlagSdkOverrideContext(); - const { reloadOnFlagChangeIsEnabled } = useToolbarState(); + const { reloadOnFlagChangeIsEnabled, includeDeprecatedFlags, includeArchivedFlags } = useToolbarState(); const { searchTerms } = useTabSearchContext(); const searchTerm = useMemo(() => searchTerms['flags'] || '', [searchTerms]); const { activeFilters } = useSubtabFilters('flags'); @@ -235,9 +272,21 @@ function SdkFlagList() { isOverridden: flag.isOverridden, type: flag.type, availableVariations: flag.availableVariations, + archived: flag.archived, + deprecated: flag.deprecated, })); }, [allFlags]); + const lifecycleEligibleCount = useMemo(() => { + let n = 0; + for (const flag of normalizedFlags) { + if (passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { + n++; + } + } + return n; + }, [normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + // Filter flags based on search term and active filters const filteredFlagIndices = useMemo(() => { const searchLower = searchTerm.toLowerCase(); @@ -275,8 +324,21 @@ function SdkFlagList() { return result; }, [normalizedFlags, searchTerm, activeFilters, isStarred]); + const lifecycleFilteredIndices = useMemo(() => { + const out: number[] = []; + for (const index of filteredFlagIndices) { + const flag = normalizedFlags[index]; + if (!flag) continue; + if (!passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { + continue; + } + out.push(index); + } + return out; + }, [filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + const virtualizer = useVirtualizer({ - count: filteredFlagIndices.length, + count: lifecycleFilteredIndices.length, getScrollElement, estimateSize: () => VIRTUALIZATION.ITEM_HEIGHT + VIRTUALIZATION.GAP, overscan: VIRTUALIZATION.OVERSCAN, @@ -319,12 +381,11 @@ function SdkFlagList() { [virtualizer], ); - // Calculate stats - const totalFlags = normalizedFlags.length; - const filteredCount = filteredFlagIndices.length; - const isFiltered = searchTerm || !activeFilters.has('all'); + // Calculate stats (denominator = flags in scope for current lifecycle toggles) + const filteredCount = lifecycleFilteredIndices.length; + const statsLabel = `${filteredCount} of ${lifecycleEligibleCount} flags`; - if (filteredFlagIndices.length === 0 && !searchTerm && activeFilters.has('all')) { + if (normalizedFlags.length === 0) { return (
- 0 of {totalFlags} flags + 0 of {lifecycleEligibleCount} flags
); } + if (lifecycleFilteredIndices.length === 0 && filteredFlagIndices.length > 0) { + return ( +
+
+ 0 of {lifecycleEligibleCount} flags +
+ +
+ ); + } + return (
- - {isFiltered ? `${filteredCount} of ${totalFlags} flags` : `${totalFlags} flags`} - + {statsLabel}
{virtualizer.getVirtualItems().map((virtualItem) => { - const flagIndex = filteredFlagIndices[virtualItem.index]; + const flagIndex = lifecycleFilteredIndices[virtualItem.index]; if (flagIndex === undefined) return null; const normalizedFlag = normalizedFlags[flagIndex]; diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagListContent.tsx b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagListContent.tsx index e2d5203c..07001614 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagListContent.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagListContent.tsx @@ -1,6 +1,10 @@ -import { memo } from 'react'; import { FlagList } from './FlagList'; -export const FlagListContent = memo(function FlagListContent() { +/** + * Not wrapped in `memo`: nested `FlagList` subscribes to `ToolbarState` (e.g. lifecycle filter toggles). + * A memo boundary with no props can prevent that subtree from reconciling when only toolbar state changes, + * so the stats line would stay stale until another context (e.g. tab search) updated. + */ +export function FlagListContent() { return ; -}); +} From 50b96f876630ef19288cdd067806a2c896ab0b65 Mon Sep 17 00:00:00 2001 From: James G Date: Fri, 27 Mar 2026 14:20:44 +0000 Subject: [PATCH 5/8] feat(toolbar): add lifecycle toggles to Filter overlay and track changes - Filter UI for deprecated/archived visibility with Filter button badge context - Extend analytics for lifecycle filter events - Extend FilterButton tests for lifecycle badge counts --- .../tests/new-design/FilterButton.test.tsx | 14 ++++ .../FilterOverlay/FilterOverlay.module.css.ts | 10 +++ .../new/FilterOverlay/FilterOverlay.tsx | 68 +++++++++++++++++-- packages/toolbar/src/core/utils/analytics.ts | 7 ++ 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/packages/toolbar/src/core/tests/new-design/FilterButton.test.tsx b/packages/toolbar/src/core/tests/new-design/FilterButton.test.tsx index 4d75ffec..52285a63 100644 --- a/packages/toolbar/src/core/tests/new-design/FilterButton.test.tsx +++ b/packages/toolbar/src/core/tests/new-design/FilterButton.test.tsx @@ -22,9 +22,20 @@ vi.mock('motion/react', () => ({ vi.mock('../../ui/Toolbar/context/telemetry/AnalyticsProvider', () => ({ useAnalytics: vi.fn().mockReturnValue({ trackFilterChange: vi.fn(), + trackFlagLifecycleFilterChange: vi.fn(), }), })); +vi.mock('../../ui/Toolbar/context/state/ToolbarStateProvider', () => ({ + useToolbarState: vi.fn(() => ({ + includeDeprecatedFlags: false, + includeArchivedFlags: false, + handleToggleIncludeDeprecatedFlags: vi.fn(), + handleToggleIncludeArchivedFlags: vi.fn(), + resetFlagLifecycleFilters: vi.fn(), + })), +})); + describe('FilterButton', () => { const AllProviders = ({ children }: { children: React.ReactNode }) => ( @@ -63,6 +74,9 @@ describe('FilterButton', () => { expect(screen.getByText('All')).toBeInTheDocument(); expect(screen.getByText('Overrides')).toBeInTheDocument(); expect(screen.getByText('Starred')).toBeInTheDocument(); + expect(screen.getByText('Flag lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Include deprecated')).toBeInTheDocument(); + expect(screen.getByText('Include archived')).toBeInTheDocument(); }); it('should toggle filter when option is clicked', () => { diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.module.css.ts b/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.module.css.ts index f03f65b4..362f7c8f 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.module.css.ts +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.module.css.ts @@ -69,6 +69,16 @@ export const content = style({ padding: '8px', }); +export const sectionLabel = style({ + fontSize: '11px', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.04em', + color: 'var(--lp-color-gray-500)', + padding: '8px 12px 4px', + margin: 0, +}); + export const filterOption = style({ display: 'flex', alignItems: 'center', diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.tsx b/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.tsx index 8ebc6cae..3e9f32ff 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.tsx @@ -6,9 +6,21 @@ import { useActiveSubtabContext } from '../context/ActiveSubtabProvider'; import { SubTab } from '../types'; import { IconButton } from '../../../../Buttons/IconButton'; import { CheckIcon, FilterTuneIcon } from '../../icons'; -import { useAnalytics } from '../../../context'; +import { useAnalytics, useToolbarState } from '../../../context'; import * as styles from './FilterOverlay.module.css'; +const FLAG_LIFECYCLE_DEPRECATED: FilterOption = { + id: 'lifecycle_deprecated', + label: 'Include deprecated', + description: 'Show flags marked as deprecated in LaunchDarkly', +}; + +const FLAG_LIFECYCLE_ARCHIVED: FilterOption = { + id: 'lifecycle_archived', + label: 'Include archived', + description: 'Show archived flags', +}; + interface FilterOptionItemProps { option: FilterOption; isActive: boolean; @@ -44,10 +56,19 @@ const FilterOverlayContent = memo(function FilterOverlayContent({ subtab, onClos const overlayRef = useRef(null); const { getActiveFilters, getFilterConfig, toggleFilter, resetFilters, hasActiveNonDefaultFilters } = useFilters(); const analytics = useAnalytics(); + const { + includeDeprecatedFlags, + includeArchivedFlags, + handleToggleIncludeDeprecatedFlags, + handleToggleIncludeArchivedFlags, + resetFlagLifecycleFilters, + } = useToolbarState(); const config = getFilterConfig(subtab); const activeFilters = getActiveFilters(subtab); - const hasNonDefaultFilters = hasActiveNonDefaultFilters(subtab); + const hasChipNonDefaultFilters = hasActiveNonDefaultFilters(subtab); + const hasLifecycleNonDefault = subtab === 'flags' && (includeDeprecatedFlags || includeArchivedFlags); + const hasNonDefaultFilters = hasChipNonDefaultFilters || hasLifecycleNonDefault; const handleToggle = useCallback( (optionId: string) => { @@ -60,7 +81,22 @@ const FilterOverlayContent = memo(function FilterOverlayContent({ subtab, onClos const handleReset = useCallback(() => { resetFilters(subtab); - }, [subtab, resetFilters]); + if (subtab === 'flags') { + resetFlagLifecycleFilters(); + } + }, [subtab, resetFilters, resetFlagLifecycleFilters]); + + const handleToggleDeprecated = useCallback(() => { + const next = !includeDeprecatedFlags; + handleToggleIncludeDeprecatedFlags(); + analytics.trackFlagLifecycleFilterChange('deprecated', next); + }, [includeDeprecatedFlags, handleToggleIncludeDeprecatedFlags, analytics]); + + const handleToggleArchived = useCallback(() => { + const next = !includeArchivedFlags; + handleToggleIncludeArchivedFlags(); + analytics.trackFlagLifecycleFilterChange('archived', next); + }, [includeArchivedFlags, handleToggleIncludeArchivedFlags, analytics]); // Close on escape key useEffect(() => { @@ -126,6 +162,21 @@ const FilterOverlayContent = memo(function FilterOverlayContent({ subtab, onClos onToggle={() => handleToggle(option.id)} /> ))} + {subtab === 'flags' ? ( + <> +

Flag lifecycle

+ + + + ) : null}
@@ -146,6 +197,7 @@ export function FilterButton({ className }: FilterButtonProps) { toggleFilterOverlay, closeFilterOverlay, } = useFilters(); + const { includeDeprecatedFlags, includeArchivedFlags } = useToolbarState(); const subtab = activeSubtab as SubTab; @@ -154,15 +206,17 @@ export function FilterButton({ className }: FilterButtonProps) { return null; } - const hasActiveFilters = hasActiveNonDefaultFilters(subtab); const activeFilters = getActiveFilters(subtab); - // Count filters, excluding 'all' since it's the default - const filterCount = activeFilters.has('all') ? 0 : activeFilters.size; + const chipCount = activeFilters.has('all') ? 0 : activeFilters.size; + const lifecycleCount = subtab === 'flags' ? (includeDeprecatedFlags ? 1 : 0) + (includeArchivedFlags ? 1 : 0) : 0; + const filterCount = chipCount + lifecycleCount; + const hasAnyNonDefaultFilters = + hasActiveNonDefaultFilters(subtab) || (subtab === 'flags' && (includeDeprecatedFlags || includeArchivedFlags)); return (
} label="Filter" onClick={toggleFilterOverlay} className={className} /> - {hasActiveFilters && filterCount > 0 ? ( + {hasAnyNonDefaultFilters && filterCount > 0 ? (
{filterCount}
diff --git a/packages/toolbar/src/core/utils/analytics.ts b/packages/toolbar/src/core/utils/analytics.ts index e83afd06..8c80e049 100644 --- a/packages/toolbar/src/core/utils/analytics.ts +++ b/packages/toolbar/src/core/utils/analytics.ts @@ -191,6 +191,13 @@ export class ToolbarAnalytics { }); } + trackFlagLifecycleFilterChange(kind: 'deprecated' | 'archived', enabled: boolean): void { + this.track(EVENTS.FILTER_CHANGED, { + filter: kind === 'deprecated' ? 'lifecycle_deprecated' : 'lifecycle_archived', + action: enabled ? 'selected' : 'deselected', + }); + } + /** * Track opening a flag deeplink */ From 912472d398a0fb6ec7d2ab9fa71f0e58e4d25bfb Mon Sep 17 00:00:00 2001 From: James G Date: Fri, 27 Mar 2026 16:04:16 +0000 Subject: [PATCH 6/8] update toggle text --- .../ui/Toolbar/components/new/FilterOverlay/FilterOverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.tsx b/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.tsx index 3e9f32ff..17660e6e 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/FilterOverlay/FilterOverlay.tsx @@ -12,7 +12,7 @@ import * as styles from './FilterOverlay.module.css'; const FLAG_LIFECYCLE_DEPRECATED: FilterOption = { id: 'lifecycle_deprecated', label: 'Include deprecated', - description: 'Show flags marked as deprecated in LaunchDarkly', + description: 'Show deprecated flags', }; const FLAG_LIFECYCLE_ARCHIVED: FilterOption = { From 8395444699dd20aa3147e7187ff2f4463caed228 Mon Sep 17 00:00:00 2001 From: James G Date: Fri, 27 Mar 2026 16:37:13 +0000 Subject: [PATCH 7/8] remove duplicated filter methods --- .../core/tests/flagLifecycleFilter.test.ts | 71 ++++++++++++++++++- .../components/new/FeatureFlags/FlagList.tsx | 60 +++++----------- .../new/FeatureFlags/flagLifecycleFilter.ts | 28 ++++++++ 3 files changed, 115 insertions(+), 44 deletions(-) diff --git a/packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts b/packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts index 20514c09..ae3bcd56 100644 --- a/packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts +++ b/packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { passesFlagLifecycleFilter } from '../ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter'; +import { + countLifecycleEligibleFlags, + filterIndicesByLifecycle, + passesFlagLifecycleFilter, +} from '../ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter'; describe('passesFlagLifecycleFilter', () => { const live = { archived: false, deprecated: false }; @@ -37,3 +41,68 @@ describe('passesFlagLifecycleFilter', () => { ); }); }); + +describe('countLifecycleEligibleFlags', () => { + const flags = [ + { archived: false, deprecated: false }, + { archived: true, deprecated: false }, + { archived: false, deprecated: true }, + ]; + + it('counts only live flags when both includes are false', () => { + expect(countLifecycleEligibleFlags(flags, false, false)).toBe(1); + }); + + it('counts live + archived when includeArchived is true', () => { + expect(countLifecycleEligibleFlags(flags, false, true)).toBe(2); + }); + + it('counts live + deprecated when includeDeprecated is true', () => { + expect(countLifecycleEligibleFlags(flags, true, false)).toBe(2); + }); + + it('counts all when both toggles are true', () => { + expect(countLifecycleEligibleFlags(flags, true, true)).toBe(3); + }); + + it('returns 0 for an empty array', () => { + expect(countLifecycleEligibleFlags([], false, false)).toBe(0); + }); +}); + +describe('filterIndicesByLifecycle', () => { + const flags = [ + { archived: false, deprecated: false }, + { archived: true, deprecated: false }, + { archived: false, deprecated: true }, + ]; + + it('drops indices for archived/deprecated when includes are false', () => { + expect(filterIndicesByLifecycle([0, 1, 2], flags, false, false)).toEqual([0]); + }); + + it('keeps archived index when includeArchived is true', () => { + expect(filterIndicesByLifecycle([0, 1, 2], flags, false, true)).toEqual([0, 1]); + }); + + it('keeps deprecated index when includeDeprecated is true', () => { + expect(filterIndicesByLifecycle([0, 1, 2], flags, true, false)).toEqual([0, 2]); + }); + + it('preserves all indices when both toggles are true', () => { + expect(filterIndicesByLifecycle([0, 1, 2], flags, true, true)).toEqual([0, 1, 2]); + }); + + it('returns empty array when given empty indices', () => { + expect(filterIndicesByLifecycle([], flags, false, false)).toEqual([]); + }); + + it('skips out-of-bounds indices', () => { + expect(filterIndicesByLifecycle([0, 5, 99], flags, false, false)).toEqual([0]); + }); + + it('filters a subset of indices', () => { + expect(filterIndicesByLifecycle([0, 2], flags, false, false)).toEqual([0]); + expect(filterIndicesByLifecycle([1, 2], flags, true, true)).toEqual([1, 2]); + }); +}); diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx index ee342809..052e8b04 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx @@ -17,7 +17,7 @@ import { NormalizedFlag } from './types'; import { EnhancedFlag } from '../../../../../types/devServer'; import { GenericHelpText } from '../../GenericHelpText'; import { VIRTUALIZATION } from '../../../constants'; -import { passesFlagLifecycleFilter } from './flagLifecycleFilter'; +import { countLifecycleEligibleFlags, filterIndicesByLifecycle } from './flagLifecycleFilter'; import * as styles from './FlagList.module.css.ts'; // Dev Server Mode Component @@ -50,15 +50,10 @@ function DevServerFlagList() { })); }, [allFlags]); - const lifecycleEligibleCount = useMemo(() => { - let n = 0; - for (const flag of normalizedFlags) { - if (passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { - n++; - } - } - return n; - }, [normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + const lifecycleEligibleCount = useMemo( + () => countLifecycleEligibleFlags(normalizedFlags, includeDeprecatedFlags, includeArchivedFlags), + [normalizedFlags, includeDeprecatedFlags, includeArchivedFlags], + ); // Filter flags based on search term and active filters const filteredFlagIndices = useMemo(() => { @@ -97,18 +92,10 @@ function DevServerFlagList() { return result; }, [normalizedFlags, searchTerm, activeFilters, isStarred]); - const lifecycleFilteredIndices = useMemo(() => { - const out: number[] = []; - for (const index of filteredFlagIndices) { - const flag = normalizedFlags[index]; - if (!flag) continue; - if (!passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { - continue; - } - out.push(index); - } - return out; - }, [filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + const lifecycleFilteredIndices = useMemo( + () => filterIndicesByLifecycle(filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags), + [filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags], + ); const virtualizer = useVirtualizer({ count: lifecycleFilteredIndices.length, @@ -277,15 +264,10 @@ function SdkFlagList() { })); }, [allFlags]); - const lifecycleEligibleCount = useMemo(() => { - let n = 0; - for (const flag of normalizedFlags) { - if (passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { - n++; - } - } - return n; - }, [normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + const lifecycleEligibleCount = useMemo( + () => countLifecycleEligibleFlags(normalizedFlags, includeDeprecatedFlags, includeArchivedFlags), + [normalizedFlags, includeDeprecatedFlags, includeArchivedFlags], + ); // Filter flags based on search term and active filters const filteredFlagIndices = useMemo(() => { @@ -324,18 +306,10 @@ function SdkFlagList() { return result; }, [normalizedFlags, searchTerm, activeFilters, isStarred]); - const lifecycleFilteredIndices = useMemo(() => { - const out: number[] = []; - for (const index of filteredFlagIndices) { - const flag = normalizedFlags[index]; - if (!flag) continue; - if (!passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { - continue; - } - out.push(index); - } - return out; - }, [filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + const lifecycleFilteredIndices = useMemo( + () => filterIndicesByLifecycle(filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags), + [filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags], + ); const virtualizer = useVirtualizer({ count: lifecycleFilteredIndices.length, diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts index 2cc70472..a1959761 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts @@ -19,3 +19,31 @@ export function passesFlagLifecycleFilter( } return true; } + +/** Counts flags that pass the current lifecycle filter (denominator for "X of Y flags"). */ +export function countLifecycleEligibleFlags( + flags: readonly Pick[], + includeDeprecated: boolean, + includeArchived: boolean, +): number { + return flags.filter((f) => passesFlagLifecycleFilter(f, includeDeprecated, includeArchived)).length; +} + +/** Keeps only indices whose flags pass the lifecycle filter. */ +export function filterIndicesByLifecycle( + indices: readonly number[], + flagsByIndex: readonly Pick[], + includeDeprecated: boolean, + includeArchived: boolean, +): number[] { + const out: number[] = []; + for (const index of indices) { + const flag = flagsByIndex[index]; + if (!flag) continue; + if (!passesFlagLifecycleFilter(flag, includeDeprecated, includeArchived)) { + continue; + } + out.push(index); + } + return out; +} From ea5e759c6ebb9a3cb8106f90b727ab14e5d96587 Mon Sep 17 00:00:00 2001 From: James G Date: Fri, 27 Mar 2026 16:37:13 +0000 Subject: [PATCH 8/8] remove duplicated filter methods --- .../core/tests/flagLifecycleFilter.test.ts | 71 ++++++++++++++++++- .../components/new/FeatureFlags/FlagList.tsx | 60 +++++----------- .../new/FeatureFlags/flagLifecycleFilter.ts | 28 ++++++++ .../src/core/ui/Toolbar/utils/localStorage.ts | 2 - 4 files changed, 115 insertions(+), 46 deletions(-) diff --git a/packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts b/packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts index 20514c09..ae3bcd56 100644 --- a/packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts +++ b/packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { passesFlagLifecycleFilter } from '../ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter'; +import { + countLifecycleEligibleFlags, + filterIndicesByLifecycle, + passesFlagLifecycleFilter, +} from '../ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter'; describe('passesFlagLifecycleFilter', () => { const live = { archived: false, deprecated: false }; @@ -37,3 +41,68 @@ describe('passesFlagLifecycleFilter', () => { ); }); }); + +describe('countLifecycleEligibleFlags', () => { + const flags = [ + { archived: false, deprecated: false }, + { archived: true, deprecated: false }, + { archived: false, deprecated: true }, + ]; + + it('counts only live flags when both includes are false', () => { + expect(countLifecycleEligibleFlags(flags, false, false)).toBe(1); + }); + + it('counts live + archived when includeArchived is true', () => { + expect(countLifecycleEligibleFlags(flags, false, true)).toBe(2); + }); + + it('counts live + deprecated when includeDeprecated is true', () => { + expect(countLifecycleEligibleFlags(flags, true, false)).toBe(2); + }); + + it('counts all when both toggles are true', () => { + expect(countLifecycleEligibleFlags(flags, true, true)).toBe(3); + }); + + it('returns 0 for an empty array', () => { + expect(countLifecycleEligibleFlags([], false, false)).toBe(0); + }); +}); + +describe('filterIndicesByLifecycle', () => { + const flags = [ + { archived: false, deprecated: false }, + { archived: true, deprecated: false }, + { archived: false, deprecated: true }, + ]; + + it('drops indices for archived/deprecated when includes are false', () => { + expect(filterIndicesByLifecycle([0, 1, 2], flags, false, false)).toEqual([0]); + }); + + it('keeps archived index when includeArchived is true', () => { + expect(filterIndicesByLifecycle([0, 1, 2], flags, false, true)).toEqual([0, 1]); + }); + + it('keeps deprecated index when includeDeprecated is true', () => { + expect(filterIndicesByLifecycle([0, 1, 2], flags, true, false)).toEqual([0, 2]); + }); + + it('preserves all indices when both toggles are true', () => { + expect(filterIndicesByLifecycle([0, 1, 2], flags, true, true)).toEqual([0, 1, 2]); + }); + + it('returns empty array when given empty indices', () => { + expect(filterIndicesByLifecycle([], flags, false, false)).toEqual([]); + }); + + it('skips out-of-bounds indices', () => { + expect(filterIndicesByLifecycle([0, 5, 99], flags, false, false)).toEqual([0]); + }); + + it('filters a subset of indices', () => { + expect(filterIndicesByLifecycle([0, 2], flags, false, false)).toEqual([0]); + expect(filterIndicesByLifecycle([1, 2], flags, true, true)).toEqual([1, 2]); + }); +}); diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx index ee342809..052e8b04 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/FlagList.tsx @@ -17,7 +17,7 @@ import { NormalizedFlag } from './types'; import { EnhancedFlag } from '../../../../../types/devServer'; import { GenericHelpText } from '../../GenericHelpText'; import { VIRTUALIZATION } from '../../../constants'; -import { passesFlagLifecycleFilter } from './flagLifecycleFilter'; +import { countLifecycleEligibleFlags, filterIndicesByLifecycle } from './flagLifecycleFilter'; import * as styles from './FlagList.module.css.ts'; // Dev Server Mode Component @@ -50,15 +50,10 @@ function DevServerFlagList() { })); }, [allFlags]); - const lifecycleEligibleCount = useMemo(() => { - let n = 0; - for (const flag of normalizedFlags) { - if (passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { - n++; - } - } - return n; - }, [normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + const lifecycleEligibleCount = useMemo( + () => countLifecycleEligibleFlags(normalizedFlags, includeDeprecatedFlags, includeArchivedFlags), + [normalizedFlags, includeDeprecatedFlags, includeArchivedFlags], + ); // Filter flags based on search term and active filters const filteredFlagIndices = useMemo(() => { @@ -97,18 +92,10 @@ function DevServerFlagList() { return result; }, [normalizedFlags, searchTerm, activeFilters, isStarred]); - const lifecycleFilteredIndices = useMemo(() => { - const out: number[] = []; - for (const index of filteredFlagIndices) { - const flag = normalizedFlags[index]; - if (!flag) continue; - if (!passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { - continue; - } - out.push(index); - } - return out; - }, [filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + const lifecycleFilteredIndices = useMemo( + () => filterIndicesByLifecycle(filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags), + [filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags], + ); const virtualizer = useVirtualizer({ count: lifecycleFilteredIndices.length, @@ -277,15 +264,10 @@ function SdkFlagList() { })); }, [allFlags]); - const lifecycleEligibleCount = useMemo(() => { - let n = 0; - for (const flag of normalizedFlags) { - if (passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { - n++; - } - } - return n; - }, [normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + const lifecycleEligibleCount = useMemo( + () => countLifecycleEligibleFlags(normalizedFlags, includeDeprecatedFlags, includeArchivedFlags), + [normalizedFlags, includeDeprecatedFlags, includeArchivedFlags], + ); // Filter flags based on search term and active filters const filteredFlagIndices = useMemo(() => { @@ -324,18 +306,10 @@ function SdkFlagList() { return result; }, [normalizedFlags, searchTerm, activeFilters, isStarred]); - const lifecycleFilteredIndices = useMemo(() => { - const out: number[] = []; - for (const index of filteredFlagIndices) { - const flag = normalizedFlags[index]; - if (!flag) continue; - if (!passesFlagLifecycleFilter(flag, includeDeprecatedFlags, includeArchivedFlags)) { - continue; - } - out.push(index); - } - return out; - }, [filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags]); + const lifecycleFilteredIndices = useMemo( + () => filterIndicesByLifecycle(filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags), + [filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags], + ); const virtualizer = useVirtualizer({ count: lifecycleFilteredIndices.length, diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts index 2cc70472..a1959761 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/FeatureFlags/flagLifecycleFilter.ts @@ -19,3 +19,31 @@ export function passesFlagLifecycleFilter( } return true; } + +/** Counts flags that pass the current lifecycle filter (denominator for "X of Y flags"). */ +export function countLifecycleEligibleFlags( + flags: readonly Pick[], + includeDeprecated: boolean, + includeArchived: boolean, +): number { + return flags.filter((f) => passesFlagLifecycleFilter(f, includeDeprecated, includeArchived)).length; +} + +/** Keeps only indices whose flags pass the lifecycle filter. */ +export function filterIndicesByLifecycle( + indices: readonly number[], + flagsByIndex: readonly Pick[], + includeDeprecated: boolean, + includeArchived: boolean, +): number[] { + const out: number[] = []; + for (const index of indices) { + const flag = flagsByIndex[index]; + if (!flag) continue; + if (!passesFlagLifecycleFilter(flag, includeDeprecated, includeArchived)) { + continue; + } + out.push(index); + } + return out; +} diff --git a/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts b/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts index 83a20596..fe7bdb50 100644 --- a/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts +++ b/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts @@ -23,9 +23,7 @@ export interface ToolbarSettings { isOptedInToAnalytics: boolean; isOptedInToEnhancedAnalytics: boolean; isOptedInToSessionReplay: boolean; - /** When true, show deprecated flags in the flags list (default false = live-only) */ includeDeprecatedFlags: boolean; - /** When true, show archived flags in the flags list (default false = live-only) */ includeArchivedFlags: boolean; }