Skip to content
Open
2 changes: 2 additions & 0 deletions packages/toolbar/src/core/services/FlagStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});

Expand Down
108 changes: 108 additions & 0 deletions packages/toolbar/src/core/tests/flagLifecycleFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import {
countLifecycleEligibleFlags,
filterIndicesByLifecycle,
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,
);
});
});

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]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<ActiveSubtabProvider>
Expand Down Expand Up @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/toolbar/src/core/types/devServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ import { NormalizedFlag } from './types';
import { EnhancedFlag } from '../../../../../types/devServer';
import { GenericHelpText } from '../../GenericHelpText';
import { VIRTUALIZATION } from '../../../constants';
import { countLifecycleEligibleFlags, filterIndicesByLifecycle } 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');
Expand All @@ -44,9 +45,16 @@ function DevServerFlagList() {
isOverridden: flag.isOverridden,
type: flag.type,
availableVariations: flag.availableVariations,
archived: flag.archived,
deprecated: flag.deprecated,
}));
}, [allFlags]);

const lifecycleEligibleCount = useMemo(
() => countLifecycleEligibleFlags(normalizedFlags, includeDeprecatedFlags, includeArchivedFlags),
[normalizedFlags, includeDeprecatedFlags, includeArchivedFlags],
);

// Filter flags based on search term and active filters
const filteredFlagIndices = useMemo(() => {
const searchLower = searchTerm.toLowerCase();
Expand Down Expand Up @@ -84,8 +92,13 @@ function DevServerFlagList() {
return result;
}, [normalizedFlags, searchTerm, activeFilters, isStarred]);

const lifecycleFilteredIndices = useMemo(
() => filterIndicesByLifecycle(filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags),
[filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags],
);

const virtualizer = useVirtualizer({
count: filteredFlagIndices.length,
count: lifecycleFilteredIndices.length,
getScrollElement,
estimateSize: () => VIRTUALIZATION.ITEM_HEIGHT + VIRTUALIZATION.GAP,
overscan: VIRTUALIZATION.OVERSCAN,
Expand Down Expand Up @@ -128,12 +141,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 (
<GenericHelpText
title="No feature flags found"
Expand All @@ -146,19 +158,31 @@ function DevServerFlagList() {
return (
<div className={styles.container}>
<div className={styles.statsHeader}>
<span className={styles.statsText}>0 of {totalFlags} flags</span>
<span className={styles.statsText}>0 of {lifecycleEligibleCount} flags</span>
</div>
<GenericHelpText title="No matching flags" subtitle="Try adjusting your search or filters" />
</div>
);
}

if (lifecycleFilteredIndices.length === 0 && filteredFlagIndices.length > 0) {
return (
<div className={styles.container}>
<div className={styles.statsHeader}>
<span className={styles.statsText}>0 of {lifecycleEligibleCount} flags</span>
</div>
<GenericHelpText
title="No live flags match"
subtitle="Turn on “Include deprecated” or “Include archived” in Filters to see those flags"
/>
</div>
);
}

return (
<div className={styles.container}>
<div className={styles.statsHeader}>
<span className={styles.statsText}>
{isFiltered ? `${filteredCount} of ${totalFlags} flags` : `${totalFlags} flags`}
</span>
<span className={styles.statsText}>{statsLabel}</span>
</div>
<div
ref={scrollContainerRef}
Expand All @@ -175,7 +199,7 @@ function DevServerFlagList() {
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const flagIndex = filteredFlagIndices[virtualItem.index];
const flagIndex = lifecycleFilteredIndices[virtualItem.index];
if (flagIndex === undefined) return null;

const normalizedFlag = normalizedFlags[flagIndex];
Expand Down Expand Up @@ -213,7 +237,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');
Expand All @@ -235,9 +259,16 @@ function SdkFlagList() {
isOverridden: flag.isOverridden,
type: flag.type,
availableVariations: flag.availableVariations,
archived: flag.archived,
deprecated: flag.deprecated,
}));
}, [allFlags]);

const lifecycleEligibleCount = useMemo(
() => countLifecycleEligibleFlags(normalizedFlags, includeDeprecatedFlags, includeArchivedFlags),
[normalizedFlags, includeDeprecatedFlags, includeArchivedFlags],
);

// Filter flags based on search term and active filters
const filteredFlagIndices = useMemo(() => {
const searchLower = searchTerm.toLowerCase();
Expand Down Expand Up @@ -275,8 +306,13 @@ function SdkFlagList() {
return result;
}, [normalizedFlags, searchTerm, activeFilters, isStarred]);

const lifecycleFilteredIndices = useMemo(
() => filterIndicesByLifecycle(filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags),
[filteredFlagIndices, normalizedFlags, includeDeprecatedFlags, includeArchivedFlags],
);

const virtualizer = useVirtualizer({
count: filteredFlagIndices.length,
count: lifecycleFilteredIndices.length,
getScrollElement,
estimateSize: () => VIRTUALIZATION.ITEM_HEIGHT + VIRTUALIZATION.GAP,
overscan: VIRTUALIZATION.OVERSCAN,
Expand Down Expand Up @@ -319,12 +355,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 (
<GenericHelpText
title="No feature flags found"
Expand All @@ -337,19 +372,31 @@ function SdkFlagList() {
return (
<div className={styles.container}>
<div className={styles.statsHeader}>
<span className={styles.statsText}>0 of {totalFlags} flags</span>
<span className={styles.statsText}>0 of {lifecycleEligibleCount} flags</span>
</div>
<GenericHelpText title="No matching flags" subtitle="Try adjusting your search or filters" />
</div>
);
}

if (lifecycleFilteredIndices.length === 0 && filteredFlagIndices.length > 0) {
return (
<div className={styles.container}>
<div className={styles.statsHeader}>
<span className={styles.statsText}>0 of {lifecycleEligibleCount} flags</span>
</div>
<GenericHelpText
title="No live flags match"
subtitle="Turn on “Include deprecated” or “Include archived” in Filters to see those flags"
/>
</div>
);
}

return (
<div className={styles.container}>
<div className={styles.statsHeader}>
<span className={styles.statsText}>
{isFiltered ? `${filteredCount} of ${totalFlags} flags` : `${totalFlags} flags`}
</span>
<span className={styles.statsText}>{statsLabel}</span>
</div>
<div
ref={scrollContainerRef}
Expand All @@ -366,7 +413,7 @@ function SdkFlagList() {
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const flagIndex = filteredFlagIndices[virtualItem.index];
const flagIndex = lifecycleFilteredIndices[virtualItem.index];
if (flagIndex === undefined) return null;

const normalizedFlag = normalizedFlags[flagIndex];
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <FlagList />;
});
}
Loading
Loading