From a8a3135eb8d5d524ce3a7b18b5e2700348e17ec4 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Mon, 2 Nov 2026 13:19:35 -0500 Subject: [PATCH] feat(features): Type frontend gates with scoped contracts --- .github/file-filters.yml | 9 + .github/workflows/backend.yml | 25 ++ static/AGENTS.md | 6 + static/app/components/acl/feature.spec.tsx | 112 +++++-- static/app/components/acl/feature.tsx | 5 +- .../featureFlagCommandPaletteActions.spec.tsx | 50 +++- .../ui/featureFlagCommandPaletteActions.tsx | 17 +- .../app/components/events/autofix/utils.tsx | 3 +- .../autofix/v3/codeChangesCard.stories.tsx | 6 +- .../onboardingLayout.spec.tsx | 3 +- .../frame/frameVariablesGrid.stories.tsx | 3 +- static/app/types/auth.tsx | 3 +- static/app/types/featureFlags.generated.ts | 282 ++++++++++++++++++ static/app/types/featureFlags.getsentry.ts | 18 ++ static/app/types/organization.tsx | 5 +- static/app/types/overrides.tsx | 3 +- static/app/types/project.tsx | 3 +- static/app/types/system.tsx | 3 +- static/app/utils.tsx | 5 + static/app/utils/featureFlagOverrides.spec.ts | 86 +++--- static/app/utils/featureFlagOverrides.ts | 27 +- static/app/utils/featureFlags.spec.ts | 34 +-- static/app/utils/seer/showNewSeer.spec.ts | 2 +- static/app/utils/useExperiment.spec.tsx | 11 +- static/app/utils/useExperiment.tsx | 3 +- .../utils/getSeriesRequestData.spec.tsx | 3 +- .../views/dashboards/manage/index.spec.tsx | 3 +- .../dashboards/utils/prebuiltConfigs.spec.ts | 9 +- .../dashboards/utils/prebuiltConfigs.tsx | 3 +- .../newWidgetBuilder.navigation.spec.tsx | 1 - .../metricsEquationVisualize/index.spec.tsx | 3 +- ...useTraceMetricsVisualizeModeState.spec.tsx | 3 +- .../dashboards/widgetCard/index.spec.tsx | 8 +- static/app/views/discover/homepage.spec.tsx | 3 +- static/app/views/discover/landing.spec.tsx | 3 +- static/app/views/discover/miniGraph.spec.tsx | 3 +- static/app/views/discover/results.spec.tsx | 5 +- .../discover/results/resultsChart.spec.tsx | 3 +- .../app/views/discover/table/tableActions.tsx | 3 +- .../chart/incompleteBucketTooltip.spec.tsx | 3 +- .../traceItemSearchQueryBuilder.spec.tsx | 2 +- .../conversations/conversationDetail.spec.tsx | 6 +- .../explore/conversations/layout.spec.tsx | 3 +- .../useLogsSearchQueryBuilderProps.spec.tsx | 5 +- static/app/views/explore/profiling/index.tsx | 3 +- .../explore/replays/detail/trace/index.tsx | 3 +- .../app/views/explore/replays/list.spec.tsx | 7 +- .../views/explore/spans/charts/index.spec.tsx | 5 +- .../app/views/explore/toolbar/index.spec.tsx | 2 +- .../views/insights/agentModels/settings.ts | 3 +- .../app/views/insights/agentTools/settings.ts | 3 +- .../insights/browser/resources/settings.ts | 3 +- .../insights/browser/webVitals/settings.ts | 3 +- static/app/views/insights/cache/settings.ts | 3 +- .../common/components/chartPanel.spec.tsx | 3 +- .../app/views/insights/database/settings.ts | 3 +- static/app/views/insights/http/settings.ts | 3 +- .../views/insights/mcp-prompts/settings.ts | 3 +- .../views/insights/mcp-resources/settings.ts | 3 +- .../app/views/insights/mcp-tools/settings.ts | 3 +- .../insights/mobile/appStarts/settings.ts | 3 +- .../mobile/screenRendering/settings.ts | 3 +- .../insights/mobile/screenload/settings.ts | 3 +- .../views/insights/mobile/screens/settings.ts | 3 +- static/app/views/insights/queues/settings.ts | 3 +- static/app/views/insights/settings.ts | 5 +- .../__stories__/investigationFixtureApi.tsx | 2 +- .../views/issueDetails/groupDetails.spec.tsx | 6 +- .../issueDetails/sidebar/seerDrawer.spec.tsx | 3 +- .../views/issueList/actions/index.spec.tsx | 2 +- static/app/views/issueList/taxonomies.tsx | 5 +- .../views/navigation/index.desktop.spec.tsx | 55 +++- .../views/navigation/index.mobile.spec.tsx | 8 +- .../navigation/primaryNavigationContext.tsx | 3 +- .../navigation/secondary/components.spec.tsx | 9 +- .../exploreSecondaryNavigation.spec.tsx | 13 +- .../seer/seerSecondaryNavigation.spec.tsx | 6 +- .../views/organizationStats/index.spec.tsx | 6 +- static/app/views/performance/table.spec.tsx | 5 +- .../transactionSummary/header.spec.tsx | 7 +- .../transactionEvents/eventsTable.spec.tsx | 9 +- .../transactionReplays/index.spec.tsx | 11 +- .../components/seerExplorerHeader.spec.tsx | 5 +- .../seerExplorerSidebarLayout.spec.tsx | 3 +- .../organizationAuthList.spec.tsx | 5 +- .../organizationDataForwarding/util/types.ts | 3 +- .../organizationSettingsForm.spec.tsx | 2 +- .../configureIntegration.spec.tsx | 15 +- .../sentryAppDetailedView.spec.tsx | 2 +- .../projectFilters/customFilterModal.tsx | 3 +- .../project/projectFilters/index.spec.tsx | 14 +- .../sources/customRepositories/index.spec.tsx | 6 +- .../features/disabledAlertsPage.tsx | 3 +- .../features/disabledAuthProvider.tsx | 3 +- .../features/disabledCustomInboundFilters.tsx | 3 +- .../features/disabledDashboardPage.tsx | 3 +- .../features/disabledDataForwarding.tsx | 3 +- .../features/disabledDiscardGroup.tsx | 3 +- .../features/disabledDiscover2Page.tsx | 3 +- .../features/disabledPerformancePage.tsx | 3 +- .../features/disabledRateLimits.tsx | 3 +- .../components/features/pageUpsellOverlay.tsx | 3 +- .../features/performanceNewProjectPrompt.tsx | 3 +- .../gsApp/components/features/planFeature.tsx | 10 +- .../gsApp/components/labelWithPowerIcon.tsx | 7 +- .../components/powerFeatureHovercard.tsx | 4 +- .../overrides/integrationFeatures.spec.tsx | 12 +- .../gsApp/overrides/integrationFeatures.tsx | 3 +- static/gsApp/overrides/useExperiment.spec.tsx | 86 +++--- static/gsApp/types/index.tsx | 3 +- .../views/spendAllocations/index.spec.tsx | 15 +- tests/js/fixtures/authProvider.ts | 2 +- tests/js/fixtures/log.ts | 7 +- tests/js/fixtures/tracemetrics.ts | 9 +- .../performance/initializePerformanceData.ts | 3 +- 115 files changed, 948 insertions(+), 323 deletions(-) create mode 100644 static/app/types/featureFlags.generated.ts create mode 100644 static/app/types/featureFlags.getsentry.ts diff --git a/.github/file-filters.yml b/.github/file-filters.yml index e7c6196282c3..acc47fac3ee3 100644 --- a/.github/file-filters.yml +++ b/.github/file-filters.yml @@ -68,6 +68,15 @@ frontend_lint: api_url_codegen: - added|modified: 'static/app/utils/api/knownSentryApiUrls.generated.ts' +feature_flag_types: + - added|deleted|modified: 'src/**/*.py' + - added|deleted|modified: 'static/app/types/featureFlags.generated.ts' + - added|deleted|modified: 'tools/feature_flags_to_typescript.py' + - added|deleted|modified: 'tests/sentry/features/test_manager.py' + - added|modified: '{Makefile,pyproject.toml,uv.lock,package.json,pnpm-lock.yaml,pnpm-workspace.yaml}' + - added|modified: '.github/workflows/backend.yml' + - added|modified: '.github/file-filters.yml' + embed_widget_schema: - added|modified: 'static/app/components/seer/markdown/embeds/schemas.ts' - added|modified: 'scripts/genEmbedWidgets.ts' diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 0d9aac66e7a5..15c309ac669e 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -35,6 +35,7 @@ jobs: backend_dependencies: ${{ steps.changes.outputs.backend_dependencies }} backend_api_urls: ${{ steps.changes.outputs.backend_api_urls }} backend_any_type: ${{ steps.changes.outputs.backend_any_type }} + feature_flag_types: ${{ steps.changes.outputs.feature_flag_types }} migration_lockfile: ${{ steps.changes.outputs.migration_lockfile }} migration_tests: ${{ steps.changes.outputs.migration_tests }} # When true, skip selective testing and run the full backend suite @@ -530,6 +531,29 @@ jobs: github-token: ${{ steps.token.outputs.token }} message: ':hammer_and_wrench: Sync API Urls to TypeScript' + feature-flag-types: + if: needs.files-changed.outputs.feature_flag_types == 'true' + needs: files-changed + name: feature flag types and consumers + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Setup sentry env + uses: ./.github/actions/setup-sentry + with: + mode: backend-ci + skip-devservices: 'true' + + - uses: ./.github/actions/setup-node-pnpm + + - name: Generate feature types from current registrations + run: make build-feature-flag-types + + - name: Typecheck generated feature types and consumers + run: pnpm run typecheck + migration: if: needs.files-changed.outputs.migration_lockfile == 'true' needs: files-changed @@ -686,6 +710,7 @@ jobs: calculate-shards, cli, files-changed, + feature-flag-types, requirements, migration, monolith-dbs, diff --git a/static/AGENTS.md b/static/AGENTS.md index 5158f5393a88..03f4b79dc7c6 100644 --- a/static/AGENTS.md +++ b/static/AGENTS.md @@ -45,6 +45,12 @@ Use `apiOptions` with `useQuery` from TanStack Query; **never** use the deprecat ## General Frontend Rules +After public feature registration changes, run `make build-feature-flag-types`. +`static/app/types/featureFlags.getsentry.ts` manually lists only private names already +used in public frontend code; do not duplicate public names there. Remove frontend +consumers before deleting registrations, and manually remove retired private names. +The committed snapshot may lag changes deployed in a backend-only PR. + 1. NO new Reflux stores 2. NO class components 3. NO CSS files (use [core components](./app/components/core/) or Emotion in edge cases) diff --git a/static/app/components/acl/feature.spec.tsx b/static/app/components/acl/feature.spec.tsx index da32b9285441..104c14521392 100644 --- a/static/app/components/acl/feature.spec.tsx +++ b/static/app/components/acl/feature.spec.tsx @@ -1,3 +1,4 @@ +import {expectTypeOf} from 'expect-type'; import {ConfigFixture} from 'sentry-fixture/config'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; @@ -7,13 +8,31 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import Feature from 'sentry/components/acl/feature'; import {registerOverride} from 'sentry/overrideRegistry'; import {ConfigStore} from 'sentry/stores/configStore'; +import type { + FeatureName, + GlobalFeature, + OrganizationFeature, + ProjectFeature, +} from 'sentry/types/featureFlags.generated'; describe('Feature', () => { + it('accepts only registered names in their corresponding scope', () => { + expectTypeOf<'dashboards-import'>().toMatchTypeOf(); + expectTypeOf<'custom-inbound-filters'>().toMatchTypeOf(); + expectTypeOf<'organizations:create'>().toMatchTypeOf(); + expectTypeOf<'retired-feature'>().not.toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf<'custom-inbound-filters'>().not.toMatchTypeOf(); + expectTypeOf<'dashboards-import'>().not.toMatchTypeOf(); + expectTypeOf<'retired-feature'>().not.toMatchTypeOf< + React.ComponentProps['features'] + >(); + }); const organization = OrganizationFixture({ - features: ['org-foo', 'org-bar', 'bar'], + features: ['dashboards-basic', 'dashboards-edit', 'advanced-search'], }); const project = ProjectFixture({ - features: ['project-foo', 'project-bar'], + features: ['custom-inbound-filters', 'rate-limits'], }); function WrappedFeature(props: React.ComponentProps) { @@ -27,7 +46,10 @@ describe('Feature', () => { }); it('has features', () => { - const features = ['org-foo', 'project-foo']; + const features = [ + 'dashboards-basic', + 'custom-inbound-filters', + ] satisfies FeatureName[]; render({childrenMock}, { organization, @@ -43,7 +65,11 @@ describe('Feature', () => { }); it('has features when requireAll is false', () => { - const features = ['org-foo', 'project-foo', 'apple']; + const features = [ + 'dashboards-basic', + 'custom-inbound-filters', + 'data-forwarding', + ] satisfies FeatureName[]; render( @@ -62,15 +88,18 @@ describe('Feature', () => { }); it('has no features', () => { - render({childrenMock}, { - organization, - }); + render( + {childrenMock}, + { + organization, + } + ); expect(childrenMock).toHaveBeenCalledWith({ hasFeature: false, organization, project, - features: ['org-baz'], + features: ['dashboards-import'] satisfies FeatureName[], renderDisabled: false, }); }); @@ -78,7 +107,7 @@ describe('Feature', () => { it('calls render function when no features', () => { const noFeatureRenderer = jest.fn(() => null); render( - + {childrenMock} , {organization} @@ -90,14 +119,14 @@ describe('Feature', () => { children: childrenMock, organization, project, - features: ['org-baz'], + features: ['dashboards-import'] satisfies FeatureName[], }); }); it('can specify org from props', () => { - const customOrg = OrganizationFixture({features: ['org-bazar']}); + const customOrg = OrganizationFixture({features: ['dashboards-starred']}); render( - + {childrenMock} , {organization} @@ -107,15 +136,15 @@ describe('Feature', () => { hasFeature: true, organization: customOrg, project, - features: ['org-bazar'], + features: ['dashboards-starred'] satisfies FeatureName[], renderDisabled: false, }); }); it('can specify project from props', () => { - const customProject = ProjectFixture({features: ['project-baz']}); + const customProject = ProjectFixture({features: ['discard-groups']}); render( - + {childrenMock} , {organization} @@ -125,13 +154,16 @@ describe('Feature', () => { hasFeature: true, organization, project: customProject, - features: ['project-baz'], + features: ['discard-groups'] satisfies FeatureName[], renderDisabled: false, }); }); it('handles no org/project', () => { - const features = ['org-foo', 'project-foo']; + const features = [ + 'dashboards-basic', + 'custom-inbound-filters', + ] satisfies FeatureName[]; render({childrenMock}, { organization, }); @@ -149,7 +181,9 @@ describe('Feature', () => { it('handles features prefixed with org/project', () => { render( - {childrenMock}, + + {childrenMock} + , { organization, } @@ -159,19 +193,22 @@ describe('Feature', () => { hasFeature: true, organization, project, - features: ['organizations:org-bar'], + features: ['organizations:dashboards-edit'] satisfies FeatureName[], renderDisabled: false, }); - render({childrenMock}, { - organization, - }); + render( + {childrenMock}, + { + organization, + } + ); expect(childrenMock).toHaveBeenCalledWith({ hasFeature: false, organization, project, - features: ['projects:bar'], + features: ['projects:releases'] satisfies FeatureName[], renderDisabled: false, }); }); @@ -194,7 +231,7 @@ describe('Feature', () => { hasFeature: true, organization, project, - features: ['organizations:create'], + features: ['organizations:create'] satisfies FeatureName[], renderDisabled: false, }); }); @@ -203,7 +240,10 @@ describe('Feature', () => { describe('no children', () => { it('should display renderDisabled with no feature', () => { render( - disabled}> + disabled} + >
The Child
, {organization} @@ -213,7 +253,10 @@ describe('Feature', () => { it('should display be empty when on', () => { render( - disabled}> + disabled} + >
The Child
, {organization} @@ -225,7 +268,7 @@ describe('Feature', () => { describe('as React node', () => { it('has features', () => { render( - +
The Child
, {organization} @@ -236,7 +279,7 @@ describe('Feature', () => { it('has no features', () => { render( - +
The Child
, {organization} @@ -247,7 +290,7 @@ describe('Feature', () => { it('renders a default disabled component', () => { render( - +
The Child
, {organization} @@ -261,7 +304,7 @@ describe('Feature', () => { const noFeatureRenderer = jest.fn(() => null); const children =
The Child
; render( - + {children} , {organization} @@ -274,7 +317,7 @@ describe('Feature', () => { children, organization, project, - features: ['org-baz'], + features: ['dashboards-import'] satisfies FeatureName[], }); }); }); @@ -290,7 +333,10 @@ describe('Feature', () => { it('uses overrideName if provided', () => { const children =
The Child
; render( - + {children} , {organization} @@ -303,7 +349,7 @@ describe('Feature', () => { children, organization, project, - features: ['org-bazar'], + features: ['dashboards-starred'] satisfies FeatureName[], }); }); }); diff --git a/static/app/components/acl/feature.tsx b/static/app/components/acl/feature.tsx index 364454a30f27..fb355e2036e8 100644 --- a/static/app/components/acl/feature.tsx +++ b/static/app/components/acl/feature.tsx @@ -3,6 +3,7 @@ import {useMemo} from 'react'; import {getOverride} from 'sentry/overrideRegistry'; import {ConfigStore} from 'sentry/stores/configStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import type {FeatureDisabledOverrides} from 'sentry/types/overrides'; import type {Project} from 'sentry/types/project'; @@ -28,7 +29,7 @@ type Props = { * * Use `organizations:` or `projects:` prefix strings to specify a feature with context. */ - features: string | string[]; + features: FeatureName | FeatureName[]; /** * The following properties will be set by the HoCs */ @@ -70,7 +71,7 @@ type Props = { * Common props passed to children and disabled render handlers. */ type FeatureRenderProps = { - features: string[]; + features: FeatureName[]; hasFeature: boolean; organization: Organization; project?: Project; diff --git a/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.spec.tsx b/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.spec.tsx index 0e5f0a752de6..1b54176f329b 100644 --- a/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.spec.tsx +++ b/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.spec.tsx @@ -78,20 +78,44 @@ describe('FeatureFlagCommandPaletteActions', () => { }); it('persists an override and updates the active organization immediately', () => { - const organization = OrganizationFixture({features: ['enabled-feature']}); + const organization = OrganizationFixture({features: ['dashboards-basic']}); + + localStorageWrapper.setItem( + LOCALSTORAGE_KEY, + '{"dashboards-basic":false,"other-local-feature":true}' + ); setLocalFeatureFlagOverride(organization, 'new-feature', true); - expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBe('{"new-feature":true}'); + expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBe( + '{"dashboards-basic":false,"other-local-feature":true,"new-feature":true}' + ); const updatedFeatures = OrganizationStore.get().organization?.features; expect(updatedFeatures).toHaveLength(2); - expect(updatedFeatures).toContain('enabled-feature'); + expect(updatedFeatures).not.toContain('dashboards-basic'); + expect(updatedFeatures).toContain('other-local-feature'); expect(updatedFeatures).toContain('new-feature'); - expect(organization.features).toEqual(['enabled-feature']); + expect(organization.features).toEqual(['dashboards-basic']); + }); + + it('applies an override when local storage cannot persist it', () => { + const organization = OrganizationFixture({features: []}); + const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('Storage unavailable'); + }); + + try { + setLocalFeatureFlagOverride(organization, 'new-feature', true); + + expect(OrganizationStore.get().organization?.features).toContain('new-feature'); + expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBeNull(); + } finally { + setItem.mockRestore(); + } }); it('toggles an existing feature flag without reloading', async () => { - const organization = OrganizationFixture({features: ['enabled-feature']}); + const organization = OrganizationFixture({features: ['dashboards-basic']}); render(, {organization}); await openCommandPalette(); @@ -102,18 +126,18 @@ describe('FeatureFlagCommandPaletteActions', () => { await userEvent.click( await screen.findByRole('option', {name: /Toggle Org Feature Flag/}) ); - await userEvent.click(await screen.findByRole('option', {name: /enabled-feature/})); + await userEvent.click(await screen.findByRole('option', {name: /dashboards-basic/})); expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBe( - '{"enabled-feature":false}' + '{"dashboards-basic":false}' ); expect(OrganizationStore.get().organization?.features).not.toContain( - 'enabled-feature' + 'dashboards-basic' ); }); it('keeps a disabled feature flag in the list after returning to it', async () => { - const organization = OrganizationFixture({features: ['enabled-feature']}); + const organization = OrganizationFixture({features: ['dashboards-basic']}); render(, {organization}); await openCommandPalette(); @@ -124,7 +148,7 @@ describe('FeatureFlagCommandPaletteActions', () => { await userEvent.click( await screen.findByRole('option', {name: /Toggle Org Feature Flag/}) ); - await userEvent.click(await screen.findByRole('option', {name: /enabled-feature/})); + await userEvent.click(await screen.findByRole('option', {name: /dashboards-basic/})); await waitFor(() => expect( @@ -142,7 +166,7 @@ describe('FeatureFlagCommandPaletteActions', () => { ); const disabledFlag = await screen.findByRole('option', { - name: 'enabled-feature', + name: 'dashboards-basic', }); expect(disabledFlag).toHaveTextContent('Disabled'); }); @@ -184,9 +208,9 @@ describe('FeatureFlagCommandPaletteActions', () => { }); it('clears stored overrides from the command palette', async () => { - localStorageWrapper.setItem(LOCALSTORAGE_KEY, '{"enabled-feature":false}'); + localStorageWrapper.setItem(LOCALSTORAGE_KEY, '{"dashboards-basic":false}'); render(, { - organization: OrganizationFixture({features: ['enabled-feature']}), + organization: OrganizationFixture({features: ['dashboards-basic']}), }); const reloadError = jest.spyOn(console, 'error').mockImplementation(); diff --git a/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.tsx b/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.tsx index 9e3a089a23bd..ebd7dfba4dce 100644 --- a/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.tsx +++ b/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.tsx @@ -34,18 +34,15 @@ export function setLocalFeatureFlagOverride( name: string, value: boolean ) { - FeatureFlagOverrides.singleton().setStoredOverride(name, value); - - const features = new Set(organization.features); - if (value) { - features.add(name); - } else { - features.delete(name); - } + const overrides = FeatureFlagOverrides.singleton(); + overrides.setStoredOverride(name, value); const updatedOrganization = { ...organization, - features: Array.from(features), + features: overrides.getEnabledFeatureFlagList(organization, { + ...overrides.getStoredOverrides(), + [name]: value, + }), }; addOrganizationFeaturesHandler({ organization: updatedOrganization, @@ -138,7 +135,7 @@ export function FeatureFlagCommandPaletteActions() { const names = Array.from( new Set([...organization.features, ...Object.keys(overrides)]) ).sort((a, b) => a.localeCompare(b)); - const enabled = new Set(organization.features); + const enabled = new Set(organization.features); Object.entries(overrides).forEach(([name, value]) => { if (value) { enabled.add(name); diff --git a/static/app/components/events/autofix/utils.tsx b/static/app/components/events/autofix/utils.tsx index 9efa6a7fb0d7..3f85b1ad0340 100644 --- a/static/app/components/events/autofix/utils.tsx +++ b/static/app/components/events/autofix/utils.tsx @@ -1,6 +1,7 @@ import {useCallback, useMemo} from 'react'; import {AUTOFIX_TTL_IN_DAYS} from 'sentry/components/events/autofix/types'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {Group} from 'sentry/types/group'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -18,7 +19,7 @@ const BASE_SUPPORTED_PROVIDERS = [ * it unlocks. Add new providers here as they become supported. */ const FEATURE_GATED_PROVIDERS: Array<{ - flag: string; + flag: OrganizationFeature; providerIds: string[]; }> = [ { diff --git a/static/app/components/events/autofix/v3/codeChangesCard.stories.tsx b/static/app/components/events/autofix/v3/codeChangesCard.stories.tsx index af222aee94bf..9922ad49351f 100644 --- a/static/app/components/events/autofix/v3/codeChangesCard.stories.tsx +++ b/static/app/components/events/autofix/v3/codeChangesCard.stories.tsx @@ -11,6 +11,7 @@ import type { } from 'sentry/components/events/autofix/useExplorerAutofix'; import {CodeChangesCard} from 'sentry/components/events/autofix/v3/codeChangesCard'; import * as Storybook from 'sentry/stories'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {User} from 'sentry/types/user'; import {OrganizationContext} from 'sentry/utils/organizationContext'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -496,7 +497,10 @@ export default Storybook.story('CodeChangesCard Feedback', story => { */ function FeatureWrapper({children}: {children: ReactNode}) { const organization = useOrganization(); - const features = [...organization.features, PR_ITERATION_FEATURE]; + const features: OrganizationFeature[] = [ + ...organization.features, + PR_ITERATION_FEATURE, + ]; return ( diff --git a/static/app/components/onboarding/gettingStartedDoc/onboardingLayout.spec.tsx b/static/app/components/onboarding/gettingStartedDoc/onboardingLayout.spec.tsx index 7b22ec86eb1e..129502e40acb 100644 --- a/static/app/components/onboarding/gettingStartedDoc/onboardingLayout.spec.tsx +++ b/static/app/components/onboarding/gettingStartedDoc/onboardingLayout.spec.tsx @@ -9,6 +9,7 @@ import type { OnboardingConfig, } from 'sentry/components/onboarding/gettingStartedDoc/types'; import {StepType} from 'sentry/components/onboarding/gettingStartedDoc/types'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; function makeDocsConfig(overrides: Partial = {}): Docs { const base: OnboardingConfig = { @@ -25,7 +26,7 @@ function makeDocsConfig(overrides: Partial = {}): Docs { return {onboarding: base}; } -function renderLayout(docsConfig: Docs, features: string[] = []) { +function renderLayout(docsConfig: Docs, features: OrganizationFeature[] = []) { const organization = OrganizationFixture({features}); const project = ProjectFixture({organization}); diff --git a/static/app/components/stackTrace/frame/frameVariablesGrid.stories.tsx b/static/app/components/stackTrace/frame/frameVariablesGrid.stories.tsx index 60e5b78b63cf..f4b890ad031c 100644 --- a/static/app/components/stackTrace/frame/frameVariablesGrid.stories.tsx +++ b/static/app/components/stackTrace/frame/frameVariablesGrid.stories.tsx @@ -8,6 +8,7 @@ import {Text} from '@sentry/scraps/text'; import {FrameVariablesGrid} from 'sentry/components/stackTrace/frame/frameVariablesGrid'; import * as Storybook from 'sentry/stories'; import type {NativeFrameVariable} from 'sentry/types/event'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {Meta} from 'sentry/types/group'; import {OrganizationContext} from 'sentry/utils/organizationContext'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -155,7 +156,7 @@ function VariableStory({children}: {children: ReactNode}) { const checkboxId = useId(); const organization = useOrganization(); const [enabled, setEnabled] = useState(true); - const features = organization.features.filter( + const features: OrganizationFeature[] = organization.features.filter( feature => feature !== 'native-variable-extraction' ); if (enabled) { diff --git a/static/app/types/auth.tsx b/static/app/types/auth.tsx index 535c4b2dd427..c845e1d683b1 100644 --- a/static/app/types/auth.tsx +++ b/static/app/types/auth.tsx @@ -1,4 +1,5 @@ import type {Field} from 'sentry/components/forms/types'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {ControlSiloOrganization} from './controlSiloOrganization'; @@ -162,7 +163,7 @@ export type AuthConfigResponse = AuthConfig | {nextUri: string}; export type AuthProvider = { key: string; name: string; - requiredFeature: string; + requiredFeature: `organizations:${OrganizationFeature}`; }; export type OrganizationAuthProvider = { diff --git a/static/app/types/featureFlags.generated.ts b/static/app/types/featureFlags.generated.ts new file mode 100644 index 000000000000..5e3ea1cffd38 --- /dev/null +++ b/static/app/types/featureFlags.generated.ts @@ -0,0 +1,282 @@ +// Generated by `make build-feature-flag-types`. +// Do not edit manually. + +import type { + GetsentryOrganizationFeature, + GetsentryProjectFeature, +} from './featureFlags.getsentry'; + +export type OrganizationFeature = + | GetsentryOrganizationFeature + | 'advanced-search' + | 'agentic-triage-free-cohort-killswitch' + | 'ai-issue-detection' + | 'am3-tier' + | 'anomaly-detection-alerts' + | 'api-keys' + | 'ask-seer-create-pr' + | 'attribute-management' + | 'authv2-enable-toggle' + | 'auto-release-creation' + | 'autofix-browser-notifications' + | 'autofix-page' + | 'autofix-pr-iteration' + | 'autofix-pr-iteration-manual' + | 'autofix-seer-agent-debug' + | 'azure-symbol-sources' + | 'change-alerts' + | 'code-mappings-refactor' + | 'code-review-beta' + | 'conduit-demo' + | 'context-engine-structured-page-context' + | 'continuous-profiling' + | 'continuous-profiling-perfetto' + | 'continuous-profiling-stats' + | 'crash-rate-alerts' + | 'custom-metrics' + | 'custom-symbol-sources' + | 'dashboards-basic' + | 'dashboards-details-widget' + | 'dashboards-edit' + | 'dashboards-import' + | 'dashboards-metrics-transition' + | 'dashboards-prebuilt-insights-dashboards' + | 'dashboards-starred' + | 'dashboards-user-last-visited' + | 'dashboards-web-vitals-trace-metrics' + | 'data-browsing-attribute-context' + | 'data-forwarding' + | 'data-secrecy' + | 'data-secrecy-v2' + | 'discover-basic' + | 'discover-query' + | 'discover-saved-queries-deprecation' + | 'dynamic-sampling' + | 'dynamic-sampling-custom' + | 'dynamic-sampling-minimum-sample-rate' + | 'event-attachments' + | 'explore-automatic-json-expansion-ui' + | 'explore-data-fidelity-annotations' + | 'explore-dev-features' + | 'explore-disable-deprecated-if-functions' + | 'explore-errors' + | 'explore-incremental-timeseries' + | 'explore-interpolation-and-smoothing' + | 'explore-multi-aggregate-charts' + | 'explore-saved-formulas' + | 'expose-migrated-discover-queries' + | 'external-issues-ai-generate' + | 'flamegraph-attachments' + | 'gen-ai-conversations' + | 'gen-ai-conversations-backend-transcript' + | 'gen-ai-issues-search' + | 'github-enterprise-github-com-source' + | 'granular-permission-scopes' + | 'granular-permission-scopes-ui' + | 'inbound-filters-in-product-flows' + | 'inbound-filters-name-suggestion' + | 'inbound-filters-v2' + | 'inbound-filters-v2-ui' + | 'insight-modules' + | 'insights-modules-use-eap' + | 'insights-query-date-range-limit' + | 'insights-to-dashboards-ui-rollout' + | 'insights-web-vitals-navigation-type-switcher' + | 'integrations-alert-rule' + | 'integrations-chat-unfurl' + | 'integrations-codeowners' + | 'integrations-cursor-origin' + | 'integrations-datadog' + | 'integrations-enterprise-alert-rule' + | 'integrations-enterprise-incident-management' + | 'integrations-event-hooks' + | 'integrations-gcp' + | 'integrations-github-project-management' + | 'integrations-gitlab-project-management' + | 'integrations-grafana' + | 'integrations-incident-management' + | 'integrations-issue-basic' + | 'integrations-issue-sync' + | 'integrations-scm-multi-org' + | 'integrations-slack-staging' + | 'integrations-stacktrace-link' + | 'integrations-ticket-rules' + | 'integrations-vercel' + | 'investigations' + | 'invite-members' + | 'invoice-usage-downloads' + | 'issue-ai-detected-code-health-visible' + | 'issue-ai-detected-db-visible' + | 'issue-ai-detected-general-visible' + | 'issue-ai-detected-http-visible' + | 'issue-ai-detected-runtime-performance-visible' + | 'issue-ai-detected-security-visible' + | 'issue-db-query-injection-vulnerability-visible' + | 'issue-details-generic-problem-section' + | 'issue-details-slow-query-span-data' + | 'issue-inbox' + | 'issue-inbox-seer-rollout' + | 'issue-issue-stream-visible' + | 'issue-llm-detected-experimental-visible' + | 'issue-low-value-span-configuration-visible' + | 'issue-performance-m-n-plus-one-db-queries-experimental-visible' + | 'issue-performance-streamed-spans-exp-visible' + | 'issue-preprod-size-analysis-visible' + | 'issue-priority-assignee-ui' + | 'issue-send-test-notification-visible' + | 'issue-sourcemap-configuration-visible' + | 'issue-stream-recommended-sort-default' + | 'issue-views' + | 'jira-explicit-mapping-removals' + | 'jira-lazy-status-sync' + | 'low-priority-queries' + | 'mcp-search-events-seer-translate' + | 'measured-ingestion-delay-metadata' + | 'measured-ingestion-delay-ui' + | 'metric-alert-chartcuterie' + | 'monitor-duplication' + | 'native-variable-extraction' + | 'notification-platform.early-adopter' + | 'notification-platform.general-access' + | 'notification-platform.internal-testing' + | 'notification-platform.is-sentry' + | 'on-demand-metrics-extraction' + | 'on-demand-metrics-prefill' + | 'on-demand-metrics-ui' + | 'on-demand-metrics-ui-widgets' + | 'onboarding' + | 'onboarding-agentic-setup' + | 'onboarding-scm' + | 'onboarding-scm-messaging-experiment' + | 'onboarding-scm-project-creation' + | 'open-membership' + | 'org-ingest-subdomains' + | 'ourlogs-enabled' + | 'ourlogs-ingestion' + | 'ourlogs-regex-searches' + | 'performance-trace-cache-lifecycle' + | 'performance-transaction-deprecation-banner' + | 'performance-view' + | 'performance-web-vitals-seer-suggestions' + | 'preprod-size-analysis-pr-comments' + | 'preprod-size-monitors-frontend' + | 'profiling' + | 'profiling-function-trends' + | 'profiling-global-suspect-functions' + | 'profiling-reject-sdks' + | 'profiling-view' + | 'relay' + | 'replay-ai-summaries' + | 'replay-ai-summaries-mobile' + | 'search-query-builder-humanized-esq' + | 'search-query-builder-number-operator-conversion' + | 'seat-based-seer-enabled' + | 'seer-added' + | 'seer-agent-enable-assisted-query-code-mode' + | 'seer-agent-source-code-search' + | 'seer-assisted-query-codemode' + | 'seer-assisted-query-cross-event-explorer' + | 'seer-assisted-query-reflection' + | 'seer-automations' + | 'seer-based-priority' + | 'seer-chat-suggestions' + | 'seer-cursor-origin-support' + | 'seer-explorer' + | 'seer-explorer-allow-bash-mode' + | 'seer-explorer-chat-coding' + | 'seer-explorer-chat-prompts' + | 'seer-explorer-code-mode-tools' + | 'seer-explorer-context-engine-allow-fe-override' + | 'seer-explorer-embeds' + | 'seer-explorer-index' + | 'seer-explorer-stream' + | 'seer-explorer-thinking-blocks' + | 'seer-explorer-thinking-summary' + | 'seer-fixability-training-data' + | 'seer-infra-telemetry' + | 'seer-infra-telemetry-user-level-auth' + | 'seer-night-shift' + | 'seer-night-shift-ui' + | 'seer-primary-nav' + | 'seer-run-questions' + | 'seer-smart-assignment-assign' + | 'seer-smart-assignment-run' + | 'seer-token-based-billing-onboarding' + | 'seer-workflows-monitor-cleanup' + | 'seer-xray' + | 'sentry-pride-logo-footer' + | 'session-replay' + | 'session-replay-ui' + | 'set-grouping-config' + | 'settings-v3' + | 'shared-issues' + | 'span-stats' + | 'spans-usage-tracking' + | 'sso-basic' + | 'sso-saml2' + | 'team-insights' + | 'team-roles' + | 'token-billed-seer-enabled' + | 'top-issues-ui' + | 'trace-item-array-query-support' + | 'trace-item-details-array-fields' + | 'trace-waterfall-attribute-pinning' + | 'trace-waterfall-time-compression' + | 'trace-waterfall-version-message' + | 'tracemetrics-dashboard-table' + | 'tracemetrics-enabled' + | 'tracemetrics-ingestion' + | 'tracemetrics-pii-scrubbing-ui' + | 'unified-table-controls' + | 'universal-inbox' + | 'uptime' + | 'user-feedback-spam-ingest' + | 'visibility-explore-range-high' + | 'visibility-explore-view' + | 'workflow-alert-previews' + | 'workflow-engine-disable-legacy-alert-rule-redirects' + | 'workflow-engine-metric-detector-limit' + | 'workflow-engine-rotate-activation-id'; + +export type ProjectFeature = + | GetsentryProjectFeature + | 'custom-inbound-filters' + | 'discard-groups' + | 'discard-transaction' + | 'error-upsampling' + | 'first-event-severity-calculation' + | 'issue-action-log-activity' + | 'issue-action-log-write-to-db' + | 'profile-functions-metrics-eap-ingestion' + | 'rate-limits' + | 'relay-minidump-uploads' + | 'relay-nintendo-event-rewrite' + | 'relay-playstation-uploads' + | 'relay-upload-multipart' + | 'releases' + | 'resumable-uploads' + | 'servicehooks' + | 'similarity-embeddings' + | 'similarity-grouping-model-next' + | 'similarity-grouping-skip-fallback' + | 'similarity-indexing' + | 'similarity-view' + | 'span-metrics-extraction' + | 'span-metrics-extraction-addons' + | 'span-v2-attachment-processing' + | 'trace-attachment-processing' + | 'transaction-name-clustering-disabled' + | 'workflow-engine-performance-detectors'; + +export type GlobalFeature = + | 'auth:register' + | 'organizations:create' + | 'relocation:enabled' + | 'system:multi-region'; + +export type FeatureName = + | GlobalFeature + | OrganizationFeature + | ProjectFeature + | `organizations:${OrganizationFeature}` + | `projects:${ProjectFeature}`; diff --git a/static/app/types/featureFlags.getsentry.ts b/static/app/types/featureFlags.getsentry.ts new file mode 100644 index 000000000000..68964eb17e44 --- /dev/null +++ b/static/app/types/featureFlags.getsentry.ts @@ -0,0 +1,18 @@ +// Private feature names already referenced by the public frontend. Maintain manually. + +export type GetsentryOrganizationFeature = + | 'add-billing-metric-usage-admin' + | 'baa' + | 'delete-billing-metric-history-admin' + | 'logs-billing' + | 'monitor-seat-billing' + | 'ondemand-budgets' + | 'profiling-billing' + | 'seer-billing' + | 'seer-user-billing-launch' + | 'spend-allocations' + | 'spend-visibility-notifications' + | 'uptime-billing' + | 'vc-marketplace-active-customer'; + +export type GetsentryProjectFeature = never; diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index 363319f42fa5..43e9867422c6 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -9,6 +9,7 @@ import type {WidgetType} from 'sentry/views/dashboards/types'; import type {ReadableSavedQuery} from 'sentry/views/explore/hooks/useGetSavedQueries'; import type {Actor, Avatar, ObjectStatus, Scope} from './core'; +import type {OrganizationFeature} from './featureFlags.generated'; import type {ExternalTeam} from './integrations'; import type {OnboardingTaskStatus} from './onboarding'; import type {Project} from './project'; @@ -68,7 +69,7 @@ export interface Organization extends OrganizationSummary { defaultRole: string; enhancedPrivacy: boolean; eventsMemberAdmin: boolean; - features: string[]; + features: OrganizationFeature[]; hasGranularReplayPermissions: boolean; isDefault: boolean; isDynamicallySampled: boolean; @@ -221,7 +222,7 @@ export interface MissingMember { */ export type SharedViewOrganization = { slug: string; - features?: string[]; + features?: OrganizationFeature[]; name?: string; }; diff --git a/static/app/types/overrides.tsx b/static/app/types/overrides.tsx index 2745b93448e3..a88143fa63ce 100644 --- a/static/app/types/overrides.tsx +++ b/static/app/types/overrides.tsx @@ -11,6 +11,7 @@ import type {InstallationInfo} from 'sentry/components/pipeline/integrationGitHu import type {DateRange} from 'sentry/components/timeRangeSelector/dateRange'; import type {SelectorItems} from 'sentry/components/timeRangeSelector/selectorItems'; import type {SentryRouteObject} from 'sentry/router/types'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {DetailedProject, Project} from 'sentry/types/project'; import type {UseReplayForCriticalFlowOptions} from 'sentry/utils/replays/useReplayForCriticalFlow'; import type {UseExperimentOptions, UseExperimentResult} from 'sentry/utils/useExperiment'; @@ -384,7 +385,7 @@ type FeatureDisabledOverride = (opts: { /** * The list of features that are controlled by this override. */ - features: string[]; + features: FeatureName[]; /** * Weather the feature is or is not enabled. */ diff --git a/static/app/types/project.tsx b/static/app/types/project.tsx index 44ffe55a957d..48e848b3ba23 100644 --- a/static/app/types/project.tsx +++ b/static/app/types/project.tsx @@ -1,5 +1,6 @@ import type {Scope, TimeseriesValue} from './core'; import type {SDKUpdatesSuggestion} from './event'; +import type {ProjectFeature} from './featureFlags.generated'; import type {Organization, Team} from './organization'; import type {PlatformKey} from './platform'; import type {Deploy} from './release'; @@ -27,7 +28,7 @@ interface ProjectSummary extends AvatarProject { access: Scope[]; dateCreated: string; environments: string[]; - features: string[]; + features: ProjectFeature[]; firstEvent: string | null; firstTransactionEvent: boolean; hasAccess: boolean; diff --git a/static/app/types/system.tsx b/static/app/types/system.tsx index dfa13d8c4c27..dff287844101 100644 --- a/static/app/types/system.tsx +++ b/static/app/types/system.tsx @@ -1,5 +1,6 @@ import type {ApiResult} from 'sentry/types/api'; +import type {GlobalFeature} from './featureFlags.generated'; import type {ParntershipAgreementType} from './overrides'; import type {User} from './user'; @@ -135,7 +136,7 @@ export interface Config { distPrefix: string; dsn: string; enableAnalytics: boolean; - features: Set; + features: Set; gravatarBaseUrl: string; initialTrace: { baggage: string; diff --git a/static/app/utils.tsx b/static/app/utils.tsx index 5ac7675fd01e..141931d5629d 100644 --- a/static/app/utils.tsx +++ b/static/app/utils.tsx @@ -1,3 +1,4 @@ +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; /** * Replaces slug special chars with a space */ @@ -64,6 +65,10 @@ export const buildTeamId = (id: string) => `team:${id}`; /** * Removes the organization / project scope prefix on feature names. */ +export function descopeFeatureName( + feature: `organizations:${OrganizationFeature}` +): OrganizationFeature; +export function descopeFeatureName(feature: T): T | string; export function descopeFeatureName(feature: T): T | string { if (typeof feature !== 'string') { return feature; diff --git a/static/app/utils/featureFlagOverrides.spec.ts b/static/app/utils/featureFlagOverrides.spec.ts index 59980b3b6fd5..2efa6e6bc3e6 100644 --- a/static/app/utils/featureFlagOverrides.spec.ts +++ b/static/app/utils/featureFlagOverrides.spec.ts @@ -11,21 +11,37 @@ describe('FeatureFlagOverrides', () => { localStorage.clear(); organization = OrganizationFixture({ - features: ['enable-issues', 'enable-profiling', 'enable-replay'], + features: ['issue-views', 'profiling', 'session-replay-ui'], }); }); describe('getOverrides', () => { + it('preserves unknown names and ignores non-boolean stored values', () => { + localStorageWrapper.setItem( + LOCALSTORAGE_KEY, + JSON.stringify({'future-feature': true, 'issue-views': 'true', profiling: false}) + ); + + expect(new FeatureFlagOverrides().getStoredOverrides()).toEqual({ + 'future-feature': true, + profiling: false, + }); + }); + + it.each(['null', '[]', '42'])('ignores non-object stored data %s', stored => { + localStorageWrapper.setItem(LOCALSTORAGE_KEY, stored); + expect(new FeatureFlagOverrides().getStoredOverrides()).toEqual({}); + }); it('should return all manually overridden flags', () => { localStorageWrapper.setItem( LOCALSTORAGE_KEY, - '{"enable-issues":false,"enable-profiling":true}' + '{"issue-views":false,"profiling":true}' ); const inst = new FeatureFlagOverrides(); expect(inst.getStoredOverrides()).toEqual({ - 'enable-issues': false, - 'enable-profiling': true, + 'issue-views': false, + profiling: true, }); }); }); @@ -35,32 +51,28 @@ describe('FeatureFlagOverrides', () => { expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBeNull(); const inst = new FeatureFlagOverrides(); - inst.setStoredOverride('enable-issues', false); - expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBe( - '{"enable-issues":false}' - ); + inst.setStoredOverride('issue-views', false); + expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBe('{"issue-views":false}'); - inst.setStoredOverride('enable-issues', true); - expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBe( - '{"enable-issues":true}' - ); + inst.setStoredOverride('issue-views', true); + expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBe('{"issue-views":true}'); }); it('should preserve other flag overrides in localstorage', () => { localStorageWrapper.setItem( LOCALSTORAGE_KEY, - '{"enable-issues":true,"enable-profiling":false}' + '{"issue-views":true,"profiling":false}' ); const inst = new FeatureFlagOverrides(); - inst.setStoredOverride('enable-replay', false); + inst.setStoredOverride('session-replay-ui', false); expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBe( - '{"enable-issues":true,"enable-profiling":false,"enable-replay":false}' + '{"issue-views":true,"profiling":false,"session-replay-ui":false}' ); - inst.setStoredOverride('enable-replay', true); + inst.setStoredOverride('session-replay-ui', true); expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBe( - '{"enable-issues":true,"enable-profiling":false,"enable-replay":true}' + '{"issue-views":true,"profiling":false,"session-replay-ui":true}' ); }); @@ -68,10 +80,8 @@ describe('FeatureFlagOverrides', () => { localStorageWrapper.setItem(LOCALSTORAGE_KEY, 'this is not an object {}'); const inst = new FeatureFlagOverrides(); - inst.setStoredOverride('enable-issues', false); - expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBe( - '{"enable-issues":false}' - ); + inst.setStoredOverride('issue-views', false); + expect(localStorageWrapper.getItem(LOCALSTORAGE_KEY)).toBe('{"issue-views":false}'); }); }); @@ -79,40 +89,40 @@ describe('FeatureFlagOverrides', () => { it('should combine & remove features that are disabled locally', () => { localStorageWrapper.setItem( LOCALSTORAGE_KEY, - '{"enable-issues":false,"enable-profiling":true}' + '{"issue-views":false,"profiling":true}' ); const inst = new FeatureFlagOverrides(); // The original values expect(organization.features).toEqual([ - 'enable-issues', - 'enable-profiling', - 'enable-replay', + 'issue-views', + 'profiling', + 'session-replay-ui', ]); // The overridden values expect(inst.getStoredOverrides()).toEqual({ - 'enable-issues': false, - 'enable-profiling': true, + 'issue-views': false, + profiling: true, }); // The combined values expect(inst.getEnabledFeatureFlagList(organization)).toEqual([ - 'enable-profiling', - 'enable-replay', + 'profiling', + 'session-replay-ui', ]); }); it('should combine & add features that are listed locally, but not in the org', () => { localStorageWrapper.setItem( LOCALSTORAGE_KEY, - '{"enable-issues":false,"secret-new-feature":true,"local-only-feature":false}' + '{"issue-views":false,"secret-new-feature":true,"local-only-feature":false}' ); const inst = new FeatureFlagOverrides(); expect(inst.getEnabledFeatureFlagList(organization)).toEqual([ - 'enable-profiling', - 'enable-replay', + 'profiling', + 'session-replay-ui', 'secret-new-feature', ]); }); @@ -122,21 +132,21 @@ describe('FeatureFlagOverrides', () => { it('should override the features on an org with the combined list', () => { localStorageWrapper.setItem( LOCALSTORAGE_KEY, - '{"enable-issues":false,"secret-new-feature":true,"local-only-feature":false}' + '{"issue-views":false,"secret-new-feature":true,"local-only-feature":false}' ); const inst = new FeatureFlagOverrides(); expect(organization.features).toEqual([ - 'enable-issues', - 'enable-profiling', - 'enable-replay', + 'issue-views', + 'profiling', + 'session-replay-ui', ]); inst.loadOrg(organization); expect(organization.features).toEqual([ - 'enable-profiling', - 'enable-replay', + 'profiling', + 'session-replay-ui', 'secret-new-feature', ]); }); diff --git a/static/app/utils/featureFlagOverrides.ts b/static/app/utils/featureFlagOverrides.ts index 6f0fafe10b38..d28ed6cb640f 100644 --- a/static/app/utils/featureFlagOverrides.ts +++ b/static/app/utils/featureFlagOverrides.ts @@ -1,5 +1,7 @@ +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import {localStorageWrapper} from 'sentry/utils/localStorage'; +import {isRecord} from 'sentry/utils/object/isRecord'; type OverrideState = Record; @@ -40,7 +42,18 @@ export class FeatureFlagOverrides { public getStoredOverrides(): OverrideState { try { - return JSON.parse(localStorageWrapper.getItem(LOCALSTORAGE_KEY) ?? '{}'); + const stored: unknown = JSON.parse( + localStorageWrapper.getItem(LOCALSTORAGE_KEY) ?? '{}' + ); + const overrides: OverrideState = {}; + if (isRecord(stored)) { + for (const [name, value] of Object.entries(stored)) { + if (typeof value === 'boolean') { + overrides[name] = value; + } + } + } + return overrides; } catch { return {}; } @@ -49,9 +62,12 @@ export class FeatureFlagOverrides { /** * Return the effective featureFlags as an array, for `organization.features` */ - public getEnabledFeatureFlagList(organization: Organization): string[] { - const enabled = new Set(organization.features); - Object.entries(this.getStoredOverrides()).forEach(([override, value]) => { + public getEnabledFeatureFlagList( + organization: Organization, + overrides = this.getStoredOverrides() + ): OrganizationFeature[] { + const enabled = new Set(organization.features); + Object.entries(overrides).forEach(([override, value]) => { // TODO(ryan953): we're only dealing with booleans to start, but other types could be supported if (value) { enabled.add(override); @@ -59,7 +75,8 @@ export class FeatureFlagOverrides { enabled.delete(override); } }); - return Array.from(enabled); + // Developer overrides may name flags newer than the committed type snapshot. + return Array.from(enabled) as OrganizationFeature[]; } /** diff --git a/static/app/utils/featureFlags.spec.ts b/static/app/utils/featureFlags.spec.ts index edeeeaa8e5c8..51ef1f06213d 100644 --- a/static/app/utils/featureFlags.spec.ts +++ b/static/app/utils/featureFlags.spec.ts @@ -11,7 +11,7 @@ describe('addOrganizationFeaturesHandler', () => { beforeEach(() => { organization = OrganizationFixture({ - features: ['enable-issues', 'enable-replay'], + features: ['issue-views', 'session-replay-ui'], }); }); @@ -19,20 +19,20 @@ describe('addOrganizationFeaturesHandler', () => { const mockHandler = jest.fn(); addOrganizationFeaturesHandler({organization, handler: mockHandler}); - organization.features.includes('enable-replay'); + organization.features.includes('session-replay-ui'); organization.features.includes('replay-mobile-ui'); - organization.features.includes('enable-issues'); + organization.features.includes('issue-views'); - expect(mockHandler).toHaveBeenNthCalledWith(1, 'enable-replay', true); + expect(mockHandler).toHaveBeenNthCalledWith(1, 'session-replay-ui', true); expect(mockHandler).toHaveBeenNthCalledWith(2, 'replay-mobile-ui', false); - expect(mockHandler).toHaveBeenNthCalledWith(3, 'enable-issues', true); + expect(mockHandler).toHaveBeenNthCalledWith(3, 'issue-views', true); }); it('should not change the functionality of `includes`', () => { const mockHandler = jest.fn(); addOrganizationFeaturesHandler({organization, handler: mockHandler}); - expect(organization.features).toContain('enable-issues'); - expect(organization.features).toContain('enable-replay'); + expect(organization.features).toContain('issue-views'); + expect(organization.features).toContain('session-replay-ui'); expect(organization.features).not.toContain('replay-mobile-ui'); }); }); @@ -42,7 +42,7 @@ describe('addProjectFeaturesHandler', () => { beforeEach(() => { project = ProjectFixture({ - features: ['enable-issues', 'enable-replay'], + features: ['custom-inbound-filters', 'rate-limits'], }); }); @@ -50,20 +50,20 @@ describe('addProjectFeaturesHandler', () => { const mockHandler = jest.fn(); addProjectFeaturesHandler({project, handler: mockHandler}); - project.features.includes('enable-replay'); - project.features.includes('replay-mobile-ui'); - project.features.includes('enable-issues'); + project.features.includes('rate-limits'); + project.features.includes('discard-groups'); + project.features.includes('custom-inbound-filters'); - expect(mockHandler).toHaveBeenNthCalledWith(1, 'enable-replay', true); - expect(mockHandler).toHaveBeenNthCalledWith(2, 'replay-mobile-ui', false); - expect(mockHandler).toHaveBeenNthCalledWith(3, 'enable-issues', true); + expect(mockHandler).toHaveBeenNthCalledWith(1, 'rate-limits', true); + expect(mockHandler).toHaveBeenNthCalledWith(2, 'discard-groups', false); + expect(mockHandler).toHaveBeenNthCalledWith(3, 'custom-inbound-filters', true); }); it('should not change the functionality of `includes`', () => { const mockHandler = jest.fn(); addProjectFeaturesHandler({project, handler: mockHandler}); - expect(project.features).toContain('enable-issues'); - expect(project.features).toContain('enable-replay'); - expect(project.features).not.toContain('replay-mobile-ui'); + expect(project.features).toContain('custom-inbound-filters'); + expect(project.features).toContain('rate-limits'); + expect(project.features).not.toContain('discard-groups'); }); }); diff --git a/static/app/utils/seer/showNewSeer.spec.ts b/static/app/utils/seer/showNewSeer.spec.ts index 07b945fe62a8..847ccca6d401 100644 --- a/static/app/utils/seer/showNewSeer.spec.ts +++ b/static/app/utils/seer/showNewSeer.spec.ts @@ -70,7 +70,7 @@ describe('showNewSeer', () => { it('returns false with unrelated features', () => { const organization = OrganizationFixture({ - features: ['some-other-feature', 'another-feature'], + features: ['dashboards-basic', 'dashboards-edit'], }); expect(showNewSeer(organization)).toBe(false); diff --git a/static/app/utils/useExperiment.spec.tsx b/static/app/utils/useExperiment.spec.tsx index 1de52a407a13..eb82a58f8620 100644 --- a/static/app/utils/useExperiment.spec.tsx +++ b/static/app/utils/useExperiment.spec.tsx @@ -2,9 +2,10 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen} from 'sentry-test/reactTestingLibrary'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {useExperiment} from 'sentry/utils/useExperiment'; -function TestComponent({feature}: {feature: string}) { +function TestComponent({feature}: {feature: OrganizationFeature}) { const {inExperiment, experimentAssignment} = useExperiment({ feature, reportExposure: false, @@ -19,14 +20,16 @@ function TestComponent({feature}: {feature: string}) { describe('useExperiment (open-source fallback)', () => { it('returns control group when feature is not enabled', () => { - render(); + render(); expect(screen.getByTestId('in-experiment')).toHaveTextContent('false'); expect(screen.getByTestId('assignment')).toHaveTextContent('control'); }); it('returns inExperiment true when feature is enabled', () => { - const org = OrganizationFixture({features: ['test-experiment']}); - render(, {organization: org}); + const org = OrganizationFixture({features: ['investigations']}); + render(, { + organization: org, + }); expect(screen.getByTestId('in-experiment')).toHaveTextContent('true'); expect(screen.getByTestId('assignment')).toHaveTextContent('control'); }); diff --git a/static/app/utils/useExperiment.tsx b/static/app/utils/useExperiment.tsx index f62c930c49bd..7e53ebf663c8 100644 --- a/static/app/utils/useExperiment.tsx +++ b/static/app/utils/useExperiment.tsx @@ -1,4 +1,5 @@ import {getOverride} from 'sentry/overrideRegistry'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {useOrganization} from 'sentry/utils/useOrganization'; export interface UseExperimentResult { @@ -20,7 +21,7 @@ export interface UseExperimentOptions { * The experiment key, matching the flagpole flag name without the * "organizations:" prefix (e.g. "my-experiment"). */ - feature: string; + feature: OrganizationFeature; /** * Whether to report that the user has been exposed to this experiment. * Required so every call site is intentional: pass true only where the diff --git a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx index 40e76e132912..b3b867602636 100644 --- a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx +++ b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx @@ -3,6 +3,7 @@ import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; import {WidgetFixture} from 'sentry-fixture/widget'; import {WidgetQueryFixture} from 'sentry-fixture/widgetQuery'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import { getSeriesRequestData, @@ -442,7 +443,7 @@ describe('utils', () => { expect(params).not.toHaveProperty(['transformations[2]']); }); - it.each([ + it.each<{displayType: DisplayType; features: OrganizationFeature[]}>([ {displayType: DisplayType.LINE, features: []}, {displayType: DisplayType.TABLE, features: ['explore-interpolation-and-smoothing']}, {displayType: DisplayType.TOP_N, features: ['explore-interpolation-and-smoothing']}, diff --git a/static/app/views/dashboards/manage/index.spec.tsx b/static/app/views/dashboards/manage/index.spec.tsx index 9e22cacd789a..b4f68761a0fe 100644 --- a/static/app/views/dashboards/manage/index.spec.tsx +++ b/static/app/views/dashboards/manage/index.spec.tsx @@ -6,6 +6,7 @@ import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import {selectEvent} from 'sentry-test/selectEvent'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import ManageDashboards from 'sentry/views/dashboards/manage'; import {getPaginationPageLink} from 'sentry/views/organizationStats/utils'; @@ -14,7 +15,7 @@ const FEATURES = [ 'dashboards-edit', 'discover-query', 'dashboards-prebuilt-insights-dashboards', -]; +] satisfies FeatureName[]; describe('Dashboards > Detail', () => { const mockUnauthorizedOrg = OrganizationFixture({ diff --git a/static/app/views/dashboards/utils/prebuiltConfigs.spec.ts b/static/app/views/dashboards/utils/prebuiltConfigs.spec.ts index 9050b843caad..5232f345ec39 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs.spec.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs.spec.ts @@ -1,5 +1,6 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {NUM_DESKTOP_COLS} from 'sentry/views/dashboards/constants'; import { getPrebuiltDashboardConfig, @@ -48,15 +49,15 @@ describe('PREBUILT_DASHBOARDS', () => { describe('getPrebuiltDashboardConfig', () => { const defaultConfig = PREBUILT_DASHBOARDS[PrebuiltDashboardId.WEB_VITALS]; const variantConfig = {...defaultConfig, title: 'Web Vitals (variant)'}; - const variants = { + const variants: Parameters[2] = { [PrebuiltDashboardId.WEB_VITALS]: { - feature: 'some-feature', + feature: 'dashboards-basic' satisfies FeatureName, config: variantConfig, }, }; it('returns the default config when there is no variant', () => { - const organization = OrganizationFixture({features: ['some-feature']}); + const organization = OrganizationFixture({features: ['dashboards-basic']}); expect( getPrebuiltDashboardConfig(PrebuiltDashboardId.HTTP, organization, variants) ).toBe(PREBUILT_DASHBOARDS[PrebuiltDashboardId.HTTP]); @@ -70,7 +71,7 @@ describe('getPrebuiltDashboardConfig', () => { }); it('returns the variant when the organization has the feature', () => { - const organization = OrganizationFixture({features: ['some-feature']}); + const organization = OrganizationFixture({features: ['dashboards-basic']}); expect( getPrebuiltDashboardConfig(PrebuiltDashboardId.WEB_VITALS, organization, variants) ).toBe(variantConfig); diff --git a/static/app/views/dashboards/utils/prebuiltConfigs.tsx b/static/app/views/dashboards/utils/prebuiltConfigs.tsx index f1ff938f7d6e..fb27cf596958 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs.tsx +++ b/static/app/views/dashboards/utils/prebuiltConfigs.tsx @@ -1,3 +1,4 @@ +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import { @@ -156,7 +157,7 @@ export const PREBUILT_DASHBOARDS: Record type PrebuiltDashboardVariant = { config: PrebuiltDashboard; /** Organization feature that selects this config over the default one. */ - feature: string; + feature: OrganizationFeature; }; /** diff --git a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.navigation.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.navigation.spec.tsx index 1a8f6be0a2d0..e3e83ea76b6a 100644 --- a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.navigation.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.navigation.spec.tsx @@ -34,7 +34,6 @@ const organization = OrganizationFixture({ features: [ 'open-membership', 'visibility-explore-view', - 'discover', 'discover-basic', 'discover-query', 'dashboards-basic', diff --git a/static/app/views/dashboards/widgetBuilder/components/visualize/traceMetrics/metricsEquationVisualize/index.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/visualize/traceMetrics/metricsEquationVisualize/index.spec.tsx index cb343b6128cf..cc7e3c4cc1ba 100644 --- a/static/app/views/dashboards/widgetBuilder/components/visualize/traceMetrics/metricsEquationVisualize/index.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/visualize/traceMetrics/metricsEquationVisualize/index.spec.tsx @@ -8,6 +8,7 @@ import { within, } from 'sentry-test/reactTestingLibrary'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; import {MetricsEquationVisualize} from 'sentry/views/dashboards/widgetBuilder/components/visualize/traceMetrics/metricsEquationVisualize'; import {WidgetBuilderProvider} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext'; @@ -49,7 +50,7 @@ jest.mock('sentry/views/explore/metrics/hooks/useStableLabels', () => { }; }); -const EQUATION_FEATURES = ['tracemetrics-enabled']; +const EQUATION_FEATURES = ['tracemetrics-enabled'] satisfies FeatureName[]; const DASHBOARD_WIDGET_BUILDER_PATHNAME = '/organizations/org-slug/dashboards/new/widget/new/'; diff --git a/static/app/views/dashboards/widgetBuilder/hooks/useTraceMetricsVisualizeModeState.spec.tsx b/static/app/views/dashboards/widgetBuilder/hooks/useTraceMetricsVisualizeModeState.spec.tsx index ed17de1ab5e6..d9cbe61b6619 100644 --- a/static/app/views/dashboards/widgetBuilder/hooks/useTraceMetricsVisualizeModeState.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/hooks/useTraceMetricsVisualizeModeState.spec.tsx @@ -2,6 +2,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; import { useWidgetBuilderContext, @@ -21,7 +22,7 @@ import { VisualizeFunction, } from 'sentry/views/explore/queryParams/visualize'; -const EQUATION_FEATURES = ['tracemetrics-enabled']; +const EQUATION_FEATURES = ['tracemetrics-enabled'] satisfies FeatureName[]; const DASHBOARD_WIDGET_BUILDER_PATHNAME = '/organizations/org-slug/dashboards/new/widget/new/'; diff --git a/static/app/views/dashboards/widgetCard/index.spec.tsx b/static/app/views/dashboards/widgetCard/index.spec.tsx index 3affe74d76b8..7229b1922d13 100644 --- a/static/app/views/dashboards/widgetCard/index.spec.tsx +++ b/static/app/views/dashboards/widgetCard/index.spec.tsx @@ -13,6 +13,7 @@ import { import * as modal from 'sentry/actionCreators/modal'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {FieldKind} from 'sentry/utils/fields'; import type {DashboardFilters, Widget} from 'sentry/views/dashboards/types'; import { @@ -37,11 +38,14 @@ describe('Dashboards > WidgetCard', () => { }), }); - const renderWithProviders = (component: React.ReactElement, features: string[] = []) => + const renderWithProviders = ( + component: React.ReactElement, + features: OrganizationFeature[] = [] + ) => render(component, { organization: { ...organization, - features: [...organization.features, ...features], + features: [...organization.features, ...features] satisfies FeatureName[], }, initialRouterConfig: { route: '/organizations/:orgId/dashboard/:dashboardId/', diff --git a/static/app/views/discover/homepage.spec.tsx b/static/app/views/discover/homepage.spec.tsx index c45f1ff5e6ce..37622fa3eccc 100644 --- a/static/app/views/discover/homepage.spec.tsx +++ b/static/app/views/discover/homepage.spec.tsx @@ -13,13 +13,14 @@ import { import * as pageFilterUtils from 'sentry/components/pageFilters/persistence'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {EventView} from 'sentry/utils/discover/eventView'; import {DEFAULT_EVENT_VIEW} from 'sentry/views/discover/results/data'; import Homepage from './homepage'; describe('Discover > Homepage', () => { - const features = ['discover-query']; + const features = ['discover-query'] satisfies FeatureName[]; let organization: ReturnType; let mockHomepage: jest.Mock; diff --git a/static/app/views/discover/landing.spec.tsx b/static/app/views/discover/landing.spec.tsx index 0053403b8de3..1239094efadd 100644 --- a/static/app/views/discover/landing.spec.tsx +++ b/static/app/views/discover/landing.spec.tsx @@ -5,12 +5,13 @@ import {render, screen, within} from 'sentry-test/reactTestingLibrary'; import {selectEvent} from 'sentry-test/selectEvent'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {OrganizationContext} from 'sentry/utils/organizationContext'; import DiscoverLanding from 'sentry/views/discover/landing'; describe('Discover > Landing', () => { const eventTitle = 'Oh no something bad'; - const features = ['discover-basic', 'discover-query']; + const features = ['discover-basic', 'discover-query'] satisfies FeatureName[]; beforeEach(() => { ProjectsStore.loadInitialData([ProjectFixture()]); diff --git a/static/app/views/discover/miniGraph.spec.tsx b/static/app/views/discover/miniGraph.spec.tsx index 754281d4a432..7ff34edf27a5 100644 --- a/static/app/views/discover/miniGraph.spec.tsx +++ b/static/app/views/discover/miniGraph.spec.tsx @@ -4,13 +4,14 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render} from 'sentry-test/reactTestingLibrary'; import {EventsRequest} from 'sentry/components/charts/eventsRequest'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {EventView} from 'sentry/utils/discover/eventView'; import MiniGraph from 'sentry/views/discover/miniGraph'; jest.mock('sentry/components/charts/eventsRequest'); describe('Discover > MiniGraph', () => { - const features = ['discover-basic']; + const features = ['discover-basic'] satisfies FeatureName[]; const location = LocationFixture({ query: {query: 'tag:value'}, pathname: '/', diff --git a/static/app/views/discover/results.spec.tsx b/static/app/views/discover/results.spec.tsx index b816332ce2e0..af2b6f168ab8 100644 --- a/static/app/views/discover/results.spec.tsx +++ b/static/app/views/discover/results.spec.tsx @@ -13,6 +13,7 @@ import {selectEvent} from 'sentry-test/selectEvent'; import * as PageFilterPersistence from 'sentry/components/pageFilters/persistence'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {SavedSearchType} from 'sentry/types/group'; import {EventView} from 'sentry/utils/discover/eventView'; import Results from 'sentry/views/discover/results'; @@ -231,7 +232,7 @@ describe('Results', () => { ProjectsStore.reset(); }); describe('Events', () => { - const features = ['discover-basic']; + const features = ['discover-basic'] satisfies FeatureName[]; it('loads data when moving from an invalid to valid EventView', async () => { const organization = OrganizationFixture({ features, @@ -1467,7 +1468,7 @@ describe('Results', () => { }); describe('transactions deprecation', () => { - const features = ['discover-basic']; + const features = ['discover-basic'] satisfies FeatureName[]; it('blocks the transactions dataset and points users to Explore', async () => { const organization = OrganizationFixture({features}); diff --git a/static/app/views/discover/results/resultsChart.spec.tsx b/static/app/views/discover/results/resultsChart.spec.tsx index d7fdf531e010..a5e29b39ab6a 100644 --- a/static/app/views/discover/results/resultsChart.spec.tsx +++ b/static/app/views/discover/results/resultsChart.spec.tsx @@ -6,6 +6,7 @@ import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {EventView} from 'sentry/utils/discover/eventView'; import { DiscoverDatasets, @@ -15,7 +16,7 @@ import { import {ResultsChartContainer} from 'sentry/views/discover/results/resultsChart'; describe('Discover > ResultsChart', () => { - const features = ['discover-basic']; + const features = ['discover-basic'] satisfies FeatureName[]; const location = LocationFixture({ query: {query: 'tag:value'}, pathname: '/', diff --git a/static/app/views/discover/table/tableActions.tsx b/static/app/views/discover/table/tableActions.tsx index 3e3c0eba5645..62fc831ed3a9 100644 --- a/static/app/views/discover/table/tableActions.tsx +++ b/static/app/views/discover/table/tableActions.tsx @@ -11,6 +11,7 @@ import {FeatureDisabled} from 'sentry/components/acl/featureDisabled'; import {GuideAnchor} from 'sentry/components/assistant/guideAnchor'; import {Hovercard} from 'sentry/components/hovercard'; import {t} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {OrganizationSummary} from 'sentry/types/organization'; import {trackAnalytics} from 'sentry/utils/analytics'; import type {TableData} from 'sentry/utils/discover/discoverQuery'; @@ -118,7 +119,7 @@ type FeatureWrapperProps = Props & { function FeatureWrapper(props: FeatureWrapperProps) { const noEditMessage = t('Requires discover query feature.'); - const editFeatures = ['organizations:discover-query']; + const editFeatures: FeatureName[] = ['organizations:discover-query']; const renderDisabled = (p: any) => ( ) { +function renderPage( + features: OrganizationFeature[] = [], + query?: Record +) { return render( diff --git a/static/app/views/explore/conversations/layout.spec.tsx b/static/app/views/explore/conversations/layout.spec.tsx index 9959e39048f8..2511182f3f5e 100644 --- a/static/app/views/explore/conversations/layout.spec.tsx +++ b/static/app/views/explore/conversations/layout.spec.tsx @@ -3,6 +3,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, within} from 'sentry-test/reactTestingLibrary'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {TopBar} from 'sentry/views/navigation/topBar'; import ConversationsLayout from './layout'; @@ -15,7 +16,7 @@ const organization = OrganizationFixture({ function renderLayout( location: {pathname: string; query?: Record}, route: string, - features?: string[] + features?: OrganizationFeature[] ) { return render( diff --git a/static/app/views/explore/logs/useLogsSearchQueryBuilderProps.spec.tsx b/static/app/views/explore/logs/useLogsSearchQueryBuilderProps.spec.tsx index 22b720e708b1..18b0abdf2e85 100644 --- a/static/app/views/explore/logs/useLogsSearchQueryBuilderProps.spec.tsx +++ b/static/app/views/explore/logs/useLogsSearchQueryBuilderProps.spec.tsx @@ -4,6 +4,7 @@ import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; import {renderHookWithProviders} from 'sentry-test/reactTestingLibrary'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent'; import {FieldKind} from 'sentry/utils/fields'; import {LogsQueryParamsProvider} from 'sentry/views/explore/logs/logsQueryParamsProvider'; @@ -41,7 +42,9 @@ const validationBody: EventValidationData = { valid: false, }; -const arrayFeatures = {features: ['trace-item-array-query-support']}; +const arrayFeatures = { + features: ['trace-item-array-query-support'] satisfies FeatureName[], +}; describe('useLogsSearchQueryBuilderProps', () => { beforeEach(() => { diff --git a/static/app/views/explore/profiling/index.tsx b/static/app/views/explore/profiling/index.tsx index 303c7c3b0419..dec8d369416b 100644 --- a/static/app/views/explore/profiling/index.tsx +++ b/static/app/views/explore/profiling/index.tsx @@ -7,10 +7,11 @@ import Feature from 'sentry/components/acl/feature'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {Redirect} from 'sentry/components/redirect'; import {t} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useRedirectNavigationV2Routes} from 'sentry/views/navigation/useRedirectNavigationV2Routes'; -const profilingFeature = ['profiling']; +const profilingFeature: FeatureName[] = ['profiling']; function ProfilingContainer() { const organization = useOrganization(); diff --git a/static/app/views/explore/replays/detail/trace/index.tsx b/static/app/views/explore/replays/detail/trace/index.tsx index 160318a80d78..bd4bb3a6e03b 100644 --- a/static/app/views/explore/replays/detail/trace/index.tsx +++ b/static/app/views/explore/replays/detail/trace/index.tsx @@ -1,11 +1,12 @@ import Feature from 'sentry/components/acl/feature'; import {FeatureDisabled} from 'sentry/components/acl/featureDisabled'; import {t} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {useReplayReader} from 'sentry/utils/replays/playback/providers/replayReaderProvider'; import {useOrganization} from 'sentry/utils/useOrganization'; import {NewTraceView} from 'sentry/views/explore/replays/detail/trace/trace'; -const features = ['organizations:performance-view']; +const features: FeatureName[] = ['organizations:performance-view']; function PerfDisabled() { return ( diff --git a/static/app/views/explore/replays/list.spec.tsx b/static/app/views/explore/replays/list.spec.tsx index e460091286d1..60deae91834c 100644 --- a/static/app/views/explore/replays/list.spec.tsx +++ b/static/app/views/explore/replays/list.spec.tsx @@ -6,6 +6,7 @@ import {render, screen, waitFor, within} from 'sentry-test/reactTestingLibrary'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import {ConfigStore} from 'sentry/stores/configStore'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {useDeadRageSelectors} from 'sentry/utils/replays/hooks/useDeadRageSelectors'; import { useHaveSelectedProjectsSentAnyReplayEvents, @@ -49,10 +50,10 @@ mockUseReplayOnboardingSidebarPanel.mockReturnValue({activateSidebar: jest.fn()} const mockUseAllMobileProj = jest.mocked(useAllMobileProj); mockUseAllMobileProj.mockReturnValue({allMobileProj: false}); -const AM1_FEATURES: string[] = []; -const AM2_FEATURES: string[] = ['session-replay']; +const AM1_FEATURES: OrganizationFeature[] = []; +const AM2_FEATURES: OrganizationFeature[] = ['session-replay'] satisfies FeatureName[]; -function getMockOrganizationFixture({features}: {features: string[]}) { +function getMockOrganizationFixture({features}: {features: OrganizationFeature[]}) { const mockOrg = OrganizationFixture({ features, access: [], diff --git a/static/app/views/explore/spans/charts/index.spec.tsx b/static/app/views/explore/spans/charts/index.spec.tsx index 4c86d92eed99..5beff4a9c160 100644 --- a/static/app/views/explore/spans/charts/index.spec.tsx +++ b/static/app/views/explore/spans/charts/index.spec.tsx @@ -8,6 +8,7 @@ import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary import type {OutcomeRow} from 'sentry/components/droppedData/types'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {DurationUnit} from 'sentry/utils/discover/fields'; import type {TimeSeries} from 'sentry/views/dashboards/widgets/common/types'; import {ChartSelectionProvider} from 'sentry/views/explore/components/attributeBreakdowns/chartSelectionContext'; @@ -434,7 +435,7 @@ describe('ExploreCharts', () => { }); describe('dropped data layer', () => { - const features = ['explore-data-fidelity-annotations']; + const features = ['explore-data-fidelity-annotations'] satisfies FeatureName[]; beforeEach(() => { PageFiltersStore.onInitializeUrlState(PageFiltersFixture()); @@ -452,7 +453,7 @@ describe('ExploreCharts', () => { }: { acceptedEvents?: OutcomeRow[]; droppedEvents?: OutcomeRow[]; - organizationFeatures?: string[]; + organizationFeatures?: OrganizationFeature[]; query?: string; }) { const request = MockApiClient.addMockResponse({ diff --git a/static/app/views/explore/toolbar/index.spec.tsx b/static/app/views/explore/toolbar/index.spec.tsx index 19f6b304d565..197ca7ff5fb2 100644 --- a/static/app/views/explore/toolbar/index.spec.tsx +++ b/static/app/views/explore/toolbar/index.spec.tsx @@ -1225,7 +1225,7 @@ describe('ExploreToolbar', () => { describe('conditional aggregates', () => { const organizationWithDashboardFeatures = OrganizationFixture({ - features: ['dashboards-edit', 'incidents'], + features: ['dashboards-edit'], }); const SERIES_FILTER_PLACEHOLDER = 'Filter spans for this series'; diff --git a/static/app/views/insights/agentModels/settings.ts b/static/app/views/insights/agentModels/settings.ts index d04d19c53b4e..ead50ab39094 100644 --- a/static/app/views/insights/agentModels/settings.ts +++ b/static/app/views/insights/agentModels/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('Models'); export const BASE_URL = 'models'; @@ -8,4 +9,4 @@ export const DATA_TYPE_PLURAL = t('Models'); export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/agents/'; -export const MODULE_FEATURES = []; +export const MODULE_FEATURES: OrganizationFeature[] = []; diff --git a/static/app/views/insights/agentTools/settings.ts b/static/app/views/insights/agentTools/settings.ts index 0062bccdf5ed..21a66e70a934 100644 --- a/static/app/views/insights/agentTools/settings.ts +++ b/static/app/views/insights/agentTools/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('Tools'); export const BASE_URL = 'tools'; @@ -8,4 +9,4 @@ export const DATA_TYPE_PLURAL = t('Tools'); export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/agents/'; -export const MODULE_FEATURES = []; +export const MODULE_FEATURES: OrganizationFeature[] = []; diff --git a/static/app/views/insights/browser/resources/settings.ts b/static/app/views/insights/browser/resources/settings.ts index b938f2220295..e55415fa86bc 100644 --- a/static/app/views/insights/browser/resources/settings.ts +++ b/static/app/views/insights/browser/resources/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {ResourceSpanOps} from 'sentry/views/insights/browser/resources/types'; export const MODULE_TITLE = t('Assets'); @@ -15,4 +16,4 @@ export const DEFAULT_RESOURCE_TYPES = [ export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/insights/frontend/assets/'; -export const MODULE_FEATURES = ['insight-modules']; +export const MODULE_FEATURES: OrganizationFeature[] = ['insight-modules']; diff --git a/static/app/views/insights/browser/webVitals/settings.ts b/static/app/views/insights/browser/webVitals/settings.ts index bfcc569ad128..c73e9d1a4f5e 100644 --- a/static/app/views/insights/browser/webVitals/settings.ts +++ b/static/app/views/insights/browser/webVitals/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {SpanFields} from 'sentry/views/insights/types'; import type {SpanProperty} from 'sentry/views/insights/types'; @@ -13,7 +14,7 @@ export const MODULE_DOC_LINK = export const DEFAULT_QUERY_FILTER = 'span.op:[ui.interaction.click,ui.interaction.hover,ui.interaction.drag,ui.interaction.press,ui.webvital.cls,ui.webvital.lcp,pageload,""]'; -export const MODULE_FEATURES = ['insight-modules']; +export const MODULE_FEATURES: OrganizationFeature[] = ['insight-modules']; export const FIELD_ALIASES = { [`p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`]: 'LCP', diff --git a/static/app/views/insights/cache/settings.ts b/static/app/views/insights/cache/settings.ts index f75b4d680676..8f650afecaa6 100644 --- a/static/app/views/insights/cache/settings.ts +++ b/static/app/views/insights/cache/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {SpanQueryFilters} from 'sentry/views/insights/types'; export const MODULE_TITLE = t('Caches'); @@ -14,4 +15,4 @@ export const BASE_FILTERS: SpanQueryFilters = { export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/insights/backend/caches/'; -export const MODULE_FEATURES = ['insight-modules']; +export const MODULE_FEATURES: OrganizationFeature[] = ['insight-modules']; diff --git a/static/app/views/insights/common/components/chartPanel.spec.tsx b/static/app/views/insights/common/components/chartPanel.spec.tsx index 853f6059efb8..8f5da6f769c0 100644 --- a/static/app/views/insights/common/components/chartPanel.spec.tsx +++ b/static/app/views/insights/common/components/chartPanel.spec.tsx @@ -1,11 +1,12 @@ import {initializeOrg} from 'sentry-test/initializeOrg'; import {render, screen} from 'sentry-test/reactTestingLibrary'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {ChartPanel} from 'sentry/views/insights/common/components/chartPanel'; describe('chartPanel', () => { const {organization} = initializeOrg({ - organization: {features: ['insights-alerts', 'insight-modules']}, + organization: {features: ['insight-modules'] satisfies FeatureName[]}, }); it('should render', () => { render( diff --git a/static/app/views/insights/database/settings.ts b/static/app/views/insights/database/settings.ts index 6800ae231261..6ad43f01c35b 100644 --- a/static/app/views/insights/database/settings.ts +++ b/static/app/views/insights/database/settings.ts @@ -8,6 +8,7 @@ import { TWO_WEEKS, } from 'sentry/components/charts/utils'; import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('Queries'); export const DATA_TYPE = t('Query'); @@ -49,4 +50,4 @@ export const DISTRIBUTION_GRANULARITIES = new GranularityLadder([ export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/insights/backend/queries/'; -export const MODULE_FEATURES = ['insight-modules']; +export const MODULE_FEATURES: OrganizationFeature[] = ['insight-modules']; diff --git a/static/app/views/insights/http/settings.ts b/static/app/views/insights/http/settings.ts index f30fb3a0f4fd..e8103fd8f185 100644 --- a/static/app/views/insights/http/settings.ts +++ b/static/app/views/insights/http/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('Outbound API Requests'); export const FRONTEND_MODULE_TITLE = t('Network Requests'); @@ -14,4 +15,4 @@ export const SPAN_ID_DISPLAY_LENGTH = 16; export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/insights/backend/requests/'; -export const MODULE_FEATURES = ['insight-modules']; +export const MODULE_FEATURES: OrganizationFeature[] = ['insight-modules']; diff --git a/static/app/views/insights/mcp-prompts/settings.ts b/static/app/views/insights/mcp-prompts/settings.ts index a8df184ce528..0cfef4ef0668 100644 --- a/static/app/views/insights/mcp-prompts/settings.ts +++ b/static/app/views/insights/mcp-prompts/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('Prompts'); export const BASE_URL = 'prompts'; @@ -8,4 +9,4 @@ export const DATA_TYPE_PLURAL = t('Prompts'); export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/mcp-servers/'; -export const MODULE_FEATURES = []; +export const MODULE_FEATURES: OrganizationFeature[] = []; diff --git a/static/app/views/insights/mcp-resources/settings.ts b/static/app/views/insights/mcp-resources/settings.ts index a07a0f84be42..6f2c85c15eeb 100644 --- a/static/app/views/insights/mcp-resources/settings.ts +++ b/static/app/views/insights/mcp-resources/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('Resources'); export const BASE_URL = 'resources'; @@ -8,4 +9,4 @@ export const DATA_TYPE_PLURAL = t('Resources'); export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/mcp-servers/'; -export const MODULE_FEATURES = []; +export const MODULE_FEATURES: OrganizationFeature[] = []; diff --git a/static/app/views/insights/mcp-tools/settings.ts b/static/app/views/insights/mcp-tools/settings.ts index c89d4acf765d..5ca51fd42896 100644 --- a/static/app/views/insights/mcp-tools/settings.ts +++ b/static/app/views/insights/mcp-tools/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('Tools'); export const BASE_URL = 'tools'; @@ -8,4 +9,4 @@ export const DATA_TYPE_PLURAL = t('Tools'); export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/mcp-servers/'; -export const MODULE_FEATURES = []; +export const MODULE_FEATURES: OrganizationFeature[] = []; diff --git a/static/app/views/insights/mobile/appStarts/settings.ts b/static/app/views/insights/mobile/appStarts/settings.ts index 32591225309c..c7925b1b3d80 100644 --- a/static/app/views/insights/mobile/appStarts/settings.ts +++ b/static/app/views/insights/mobile/appStarts/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('App Starts'); export const BASE_URL = 'app-startup'; @@ -8,4 +9,4 @@ export const DATA_TYPE_PLURAL = t('App Starts'); export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/insights/mobile/app-starts/'; -export const MODULE_FEATURES = ['insight-modules']; +export const MODULE_FEATURES: OrganizationFeature[] = ['insight-modules']; diff --git a/static/app/views/insights/mobile/screenRendering/settings.ts b/static/app/views/insights/mobile/screenRendering/settings.ts index 5c924c2db976..4b8261b8ce10 100644 --- a/static/app/views/insights/mobile/screenRendering/settings.ts +++ b/static/app/views/insights/mobile/screenRendering/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('Screen Rendering'); export const DATA_TYPE = t('Screen Render'); @@ -8,4 +9,4 @@ export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/insights/mobile/' export const BASE_URL = 'screen-rendering'; -export const MODULE_FEATURES = ['insight-modules']; +export const MODULE_FEATURES: OrganizationFeature[] = ['insight-modules']; diff --git a/static/app/views/insights/mobile/screenload/settings.ts b/static/app/views/insights/mobile/screenload/settings.ts index 0897fd0de6a1..872743c1d253 100644 --- a/static/app/views/insights/mobile/screenload/settings.ts +++ b/static/app/views/insights/mobile/screenload/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('Screen Loads'); export const BASE_URL = 'screens'; @@ -8,4 +9,4 @@ export const DATA_TYPE_PLURAL = t('Screen Loads'); export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/insights/mobile/screen-loads/'; -export const MODULE_FEATURES = ['insight-modules']; +export const MODULE_FEATURES: OrganizationFeature[] = ['insight-modules']; diff --git a/static/app/views/insights/mobile/screens/settings.ts b/static/app/views/insights/mobile/screens/settings.ts index d64e00906eef..d92b2d9bc69e 100644 --- a/static/app/views/insights/mobile/screens/settings.ts +++ b/static/app/views/insights/mobile/screens/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('Mobile Vitals'); @@ -8,7 +9,7 @@ export const MODULE_DESCRIPTION = t( 'Get insights into key performance metrics of your mobile app.' ); -export const MODULE_FEATURE = t('insight-modules'); +export const MODULE_FEATURE: OrganizationFeature = 'insight-modules'; export const DATA_TYPE = t('Mobile Vitals'); export const DATA_TYPE_PLURAL = t('Mobile Vitals'); diff --git a/static/app/views/insights/queues/settings.ts b/static/app/views/insights/queues/settings.ts index 799a76e83e97..f5df5f9a1485 100644 --- a/static/app/views/insights/queues/settings.ts +++ b/static/app/views/insights/queues/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; export const MODULE_TITLE = t('Queues'); export const BASE_URL = 'queues'; @@ -8,4 +9,4 @@ export const DATA_TYPE_PLURAL = t('Queues'); export const MODULE_DOC_LINK = 'https://docs.sentry.io/product/insights/backend/queue-monitoring/'; -export const MODULE_FEATURES = ['insight-modules']; +export const MODULE_FEATURES: OrganizationFeature[] = ['insight-modules']; diff --git a/static/app/views/insights/settings.ts b/static/app/views/insights/settings.ts index 71dd97509578..dc415e51af39 100644 --- a/static/app/views/insights/settings.ts +++ b/static/app/views/insights/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import { DATA_TYPE as AGENT_MODELS_DATA_TYPE, DATA_TYPE_PLURAL as AGENT_MODELS_DATA_TYPE_PLURAL, @@ -215,7 +216,7 @@ export const MODULE_PRODUCT_DOC_LINKS = { /** * Features that control gating of modules, falling back to upsell style hooks. */ -export const MODULE_FEATURE_MAP: Record = { +export const MODULE_FEATURE_MAP: Record = { [ModuleName.DB]: DB_MODULE_FEATURES, [ModuleName.APP_START]: APP_STARTS_MODULE_FEATURES, [ModuleName.HTTP]: HTTP_MODULE_FEATURES, @@ -238,7 +239,7 @@ export const MODULE_FEATURE_MAP: Record = { /** * Features that control the visibility of modules. */ -export const MODULE_FEATURE_VISIBLE_MAP: Record = { +export const MODULE_FEATURE_VISIBLE_MAP: Record = { [ModuleName.DB]: ['insight-modules'], [ModuleName.APP_START]: ['insight-modules'], [ModuleName.HTTP]: ['insight-modules'], diff --git a/static/app/views/investigations/__stories__/investigationFixtureApi.tsx b/static/app/views/investigations/__stories__/investigationFixtureApi.tsx index 47f5703da568..1314c8f9eb1c 100644 --- a/static/app/views/investigations/__stories__/investigationFixtureApi.tsx +++ b/static/app/views/investigations/__stories__/investigationFixtureApi.tsx @@ -141,7 +141,7 @@ export function InvestigationFixtureApi({ }) ); const [ready, setReady] = useState(false); - const organization = { + const organization: typeof outerOrganization = { ...outerOrganization, slug: organizationSlug, features: featureEnabled diff --git a/static/app/views/issueDetails/groupDetails.spec.tsx b/static/app/views/issueDetails/groupDetails.spec.tsx index c103a6399397..e37743439ebd 100644 --- a/static/app/views/issueDetails/groupDetails.spec.tsx +++ b/static/app/views/issueDetails/groupDetails.spec.tsx @@ -20,6 +20,7 @@ import {ConfigStore} from 'sentry/stores/configStore'; import {GroupStore} from 'sentry/stores/groupStore'; import {OrganizationStore} from 'sentry/stores/organizationStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {IssueCategory} from 'sentry/types/group'; import GroupDetails from 'sentry/views/issueDetails/groupDetails'; import * as GroupDetailsLayoutModule from 'sentry/views/issueDetails/groupDetailsLayout'; @@ -312,7 +313,10 @@ describe('groupDetails', () => { const organization = { ...defaultInit.organization, hideAiFeatures: false, - features: [...defaultInit.organization.features, 'autofix-page'], + features: [ + ...defaultInit.organization.features, + 'autofix-page', + ] satisfies FeatureName[], }; const {router} = createWrapper( { diff --git a/static/app/views/issueDetails/sidebar/seerDrawer.spec.tsx b/static/app/views/issueDetails/sidebar/seerDrawer.spec.tsx index 6abf2fd0627f..f15f5fb0d712 100644 --- a/static/app/views/issueDetails/sidebar/seerDrawer.spec.tsx +++ b/static/app/views/issueDetails/sidebar/seerDrawer.spec.tsx @@ -13,6 +13,7 @@ import { } from 'sentry-test/reactTestingLibrary'; import {SeerDrawer} from 'sentry/components/events/autofix/v3/drawer'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; function makeExplorerBlock({ id = 'block-1', @@ -405,7 +406,7 @@ describe('SeerDrawer', () => { expect(getMock.mock.calls).toHaveLength(callsAfterLoad); }); - it.each([['autofix-pr-iteration'], ['autofix-pr-iteration-manual']])( + it.each(['autofix-pr-iteration', 'autofix-pr-iteration-manual'])( 'polls the autofix endpoint when %s is enabled and a PR exists', async feature => { const getMock = mockAutofixWithPr(); diff --git a/static/app/views/issueList/actions/index.spec.tsx b/static/app/views/issueList/actions/index.spec.tsx index c32ea1df4abb..9cc186b2d927 100644 --- a/static/app/views/issueList/actions/index.spec.tsx +++ b/static/app/views/issueList/actions/index.spec.tsx @@ -623,7 +623,7 @@ describe('IssueListActions', () => { describe('bulk action performance issues', () => { const orgWithPerformanceIssues = OrganizationFixture({ - features: ['performance-issues'], + features: [], access: ['event:admin'], }); diff --git a/static/app/views/issueList/taxonomies.tsx b/static/app/views/issueList/taxonomies.tsx index 5b897b3b4459..9cc044747365 100644 --- a/static/app/views/issueList/taxonomies.tsx +++ b/static/app/views/issueList/taxonomies.tsx @@ -3,9 +3,10 @@ import type {ReactNode} from 'react'; import type {FeatureBadgeProps} from '@sentry/scraps/badge'; import {t} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {IssueCategory} from 'sentry/types/group'; -const SENTRY_CONFIGURATION_FEATURE_FLAGS = [ +const SENTRY_CONFIGURATION_FEATURE_FLAGS: OrganizationFeature[] = [ 'issue-sourcemap-configuration-visible', 'issue-low-value-span-configuration-visible', ]; @@ -25,7 +26,7 @@ export const ISSUE_TAXONOMY_CONFIG: Record< key: string; label: string; badge?: FeatureBadgeProps['type']; - featureFlags?: string[]; + featureFlags?: OrganizationFeature[]; } > = { [IssueTaxonomy.ERRORS_AND_OUTAGES]: { diff --git a/static/app/views/navigation/index.desktop.spec.tsx b/static/app/views/navigation/index.desktop.spec.tsx index 23c1b7c12514..c02ee6fc836c 100644 --- a/static/app/views/navigation/index.desktop.spec.tsx +++ b/static/app/views/navigation/index.desktop.spec.tsx @@ -16,13 +16,13 @@ import { import {ConfigStore} from 'sentry/stores/configStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {Navigation} from 'sentry/views/navigation'; import {NAVIGATION_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY} from 'sentry/views/navigation/constants'; import {PrimaryNavigationContextProvider} from 'sentry/views/navigation/primaryNavigationContext'; const ALL_AVAILABLE_FEATURES = [ 'insight-modules', - 'discover', 'discover-basic', 'discover-query', 'dashboards-basic', @@ -33,7 +33,7 @@ const ALL_AVAILABLE_FEATURES = [ 'performance-view', 'profiling', 'visibility-explore-view', -]; +] satisfies FeatureName[]; const mockUsingCustomerDomain = jest.fn(); @@ -231,7 +231,9 @@ describe('desktop navigation', () => { , navigationContext({ - organization: {features: [...ALL_AVAILABLE_FEATURES, 'settings-v3']}, + organization: { + features: [...ALL_AVAILABLE_FEATURES, 'settings-v3'] satisfies FeatureName[], + }, initialRouterConfig: { location: {pathname: '/settings/org-slug/projects/project-slug/'}, route: '/settings/:orgId/projects/:projectId/', @@ -253,7 +255,9 @@ describe('desktop navigation', () => { }); describe('settings v3', () => { - const settingsV3 = {features: [...ALL_AVAILABLE_FEATURES, 'settings-v3']}; + const settingsV3 = { + features: [...ALL_AVAILABLE_FEATURES, 'settings-v3'] satisfies FeatureName[], + }; beforeEach(() => { ProjectsStore.loadInitialData([ @@ -606,7 +610,7 @@ describe('desktop navigation', () => { , navigationContext({ - organization: {features: [...ALL_AVAILABLE_FEATURES]}, + organization: {features: [...ALL_AVAILABLE_FEATURES] satisfies FeatureName[]}, }) ); @@ -635,7 +639,10 @@ describe('desktop navigation', () => { , navigationContext({ organization: { - features: [...ALL_AVAILABLE_FEATURES, 'insights-to-dashboards-ui-rollout'], + features: [ + ...ALL_AVAILABLE_FEATURES, + 'insights-to-dashboards-ui-rollout', + ] satisfies FeatureName[], }, }) ); @@ -934,7 +941,11 @@ describe('desktop navigation', () => { it('shows Seer between Issues and Explore with seer-primary-nav', () => { const primaryNav = renderSeerNavigation({ - features: [...ALL_AVAILABLE_FEATURES, 'seer-primary-nav', 'seer-automations'], + features: [ + ...ALL_AVAILABLE_FEATURES, + 'seer-primary-nav', + 'seer-automations', + ] satisfies FeatureName[], }); const links = within(primaryNav).getAllByRole('link'); @@ -945,7 +956,11 @@ describe('desktop navigation', () => { it('hides Seer without seer-primary-nav, even with investigations and automations', () => { const primaryNav = renderSeerNavigation({ - features: [...ALL_AVAILABLE_FEATURES, 'investigations', 'seer-automations'], + features: [ + ...ALL_AVAILABLE_FEATURES, + 'investigations', + 'seer-automations', + ] satisfies FeatureName[], openMembership: true, }); @@ -956,7 +971,11 @@ describe('desktop navigation', () => { it('hides Seer with seer-primary-nav but neither investigations nor automations', () => { const primaryNav = renderSeerNavigation({ - features: [...ALL_AVAILABLE_FEATURES, 'seer-primary-nav', 'investigations'], + features: [ + ...ALL_AVAILABLE_FEATURES, + 'seer-primary-nav', + 'investigations', + ] satisfies FeatureName[], openMembership: false, }); @@ -967,7 +986,11 @@ describe('desktop navigation', () => { it('links Seer to automations when automations is enabled', () => { const primaryNav = renderSeerNavigation({ - features: [...ALL_AVAILABLE_FEATURES, 'seer-primary-nav', 'seer-automations'], + features: [ + ...ALL_AVAILABLE_FEATURES, + 'seer-primary-nav', + 'seer-automations', + ] satisfies FeatureName[], }); expect(within(primaryNav).getByRole('link', {name: 'Seer'})).toHaveAttribute( @@ -978,7 +1001,11 @@ describe('desktop navigation', () => { it('links Seer to investigations when automations is disabled', () => { const primaryNav = renderSeerNavigation({ - features: [...ALL_AVAILABLE_FEATURES, 'seer-primary-nav', 'investigations'], + features: [ + ...ALL_AVAILABLE_FEATURES, + 'seer-primary-nav', + 'investigations', + ] satisfies FeatureName[], openMembership: true, }); @@ -991,7 +1018,11 @@ describe('desktop navigation', () => { it('marks Seer as active on seer routes', () => { const primaryNav = renderSeerNavigation( { - features: [...ALL_AVAILABLE_FEATURES, 'seer-primary-nav', 'investigations'], + features: [ + ...ALL_AVAILABLE_FEATURES, + 'seer-primary-nav', + 'investigations', + ] satisfies FeatureName[], openMembership: true, }, '/organizations/org-slug/seer/investigations/investigation-1/' diff --git a/static/app/views/navigation/index.mobile.spec.tsx b/static/app/views/navigation/index.mobile.spec.tsx index 330666d44186..669cb4477515 100644 --- a/static/app/views/navigation/index.mobile.spec.tsx +++ b/static/app/views/navigation/index.mobile.spec.tsx @@ -14,12 +14,12 @@ import { import {mockMatchMedia} from 'sentry-test/utils'; import {ConfigStore} from 'sentry/stores/configStore'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {Navigation} from 'sentry/views/navigation'; import {PrimaryNavigationContextProvider} from 'sentry/views/navigation/primaryNavigationContext'; const ALL_AVAILABLE_FEATURES = [ 'insight-modules', - 'discover', 'discover-basic', 'discover-query', 'dashboards-basic', @@ -29,7 +29,7 @@ const ALL_AVAILABLE_FEATURES = [ 'performance-view', 'profiling', 'visibility-explore-view', -]; +] satisfies FeatureName[]; function navigationContext({ organization, @@ -100,7 +100,9 @@ describe('mobile navigation', () => { }); describe('settings v3', () => { - const settingsV3 = {features: [...ALL_AVAILABLE_FEATURES, 'settings-v3']}; + const settingsV3 = { + features: [...ALL_AVAILABLE_FEATURES, 'settings-v3'] satisfies FeatureName[], + }; it('uses the settings scope rail', async () => { const {router} = render( diff --git a/static/app/views/navigation/primaryNavigationContext.tsx b/static/app/views/navigation/primaryNavigationContext.tsx index ed6d2cc3dfe5..3c812258da9e 100644 --- a/static/app/views/navigation/primaryNavigationContext.tsx +++ b/static/app/views/navigation/primaryNavigationContext.tsx @@ -5,6 +5,7 @@ import * as Sentry from '@sentry/react'; import {useMedia} from '@sentry/scraps/useMedia'; import {USING_CUSTOMER_DOMAIN} from 'sentry/constants'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; import {SecondaryNavigationContextProvider} from 'sentry/views/navigation/secondaryNavigationContext'; @@ -29,7 +30,7 @@ const PRIMARY_NAVIGATION_ROUTE_OVERRIDES: Array<{ group: NavigationGroup; pattern: RegExp; /** Only apply the override when the organization lacks this feature */ - unlessFeature?: string; + unlessFeature?: OrganizationFeature; }> = [ // Seer pages linked from Explore until the Seer primary nav ships. { diff --git a/static/app/views/navigation/secondary/components.spec.tsx b/static/app/views/navigation/secondary/components.spec.tsx index 38e679d6274d..9a43a5b6a150 100644 --- a/static/app/views/navigation/secondary/components.spec.tsx +++ b/static/app/views/navigation/secondary/components.spec.tsx @@ -5,6 +5,7 @@ import {UserFixture} from 'sentry-fixture/user'; import {render, screen, within} from 'sentry-test/reactTestingLibrary'; import {ConfigStore} from 'sentry/stores/configStore'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {Navigation} from 'sentry/views/navigation'; import { NAVIGATION_SECONDARY_SIDEBAR_DATA_ATTRIBUTE, @@ -15,7 +16,6 @@ import {SecondaryNavigationContextProvider} from 'sentry/views/navigation/second const ALL_AVAILABLE_FEATURES = [ 'insight-modules', - 'discover', 'discover-basic', 'discover-query', 'dashboards-basic', @@ -25,7 +25,7 @@ const ALL_AVAILABLE_FEATURES = [ 'performance-view', 'profiling', 'visibility-explore-view', -]; +] satisfies FeatureName[]; function setupMocks() { localStorage.clear(); @@ -72,7 +72,10 @@ describe('SecondarySidebar', () => { , { organization: OrganizationFixture({ - features: [...ALL_AVAILABLE_FEATURES, ...(settingsV3 ? ['settings-v3'] : [])], + features: [ + ...ALL_AVAILABLE_FEATURES, + ...(settingsV3 ? (['settings-v3'] satisfies OrganizationFeature[]) : []), + ], }), initialRouterConfig: {location: {pathname: '/organizations/org-slug/issues/'}}, } diff --git a/static/app/views/navigation/secondary/sections/explore/exploreSecondaryNavigation.spec.tsx b/static/app/views/navigation/secondary/sections/explore/exploreSecondaryNavigation.spec.tsx index f577f0dbe90d..5a2a55d5bd7d 100644 --- a/static/app/views/navigation/secondary/sections/explore/exploreSecondaryNavigation.spec.tsx +++ b/static/app/views/navigation/secondary/sections/explore/exploreSecondaryNavigation.spec.tsx @@ -1,6 +1,7 @@ import {initializeOrg} from 'sentry-test/initializeOrg'; import {render, screen} from 'sentry-test/reactTestingLibrary'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {Navigation} from 'sentry/views/navigation'; import {PrimaryNavigationContextProvider} from 'sentry/views/navigation/primaryNavigationContext'; import {SecondaryNavigationContextProvider} from 'sentry/views/navigation/secondaryNavigationContext'; @@ -8,7 +9,7 @@ import {SecondaryNavigationContextProvider} from 'sentry/views/navigation/second describe('ExploreSecondaryNavigation', () => { const {organization} = initializeOrg({ organization: { - features: ['performance-view', 'visibility-explore-view'], + features: ['performance-view', 'visibility-explore-view'] satisfies FeatureName[], }, }); @@ -60,10 +61,14 @@ describe('ExploreSecondaryNavigation', () => { expect(screen.queryByText('Investigations')).not.toBeInTheDocument(); }); - function renderExploreNavigation(features: string[]) { + function renderExploreNavigation(features: OrganizationFeature[]) { const {organization: seerOrganization} = initializeOrg({ organization: { - features: ['performance-view', 'visibility-explore-view', ...features], + features: [ + 'performance-view', + 'visibility-explore-view', + ...features, + ] satisfies FeatureName[], openMembership: true, }, }); @@ -150,7 +155,7 @@ describe('ExploreSecondaryNavigation', () => { 'visibility-explore-view', 'discover-basic', 'discover-query', - ], + ] satisfies FeatureName[], }, }); diff --git a/static/app/views/navigation/secondary/sections/seer/seerSecondaryNavigation.spec.tsx b/static/app/views/navigation/secondary/sections/seer/seerSecondaryNavigation.spec.tsx index a0c23a3d3037..e3e0309818ca 100644 --- a/static/app/views/navigation/secondary/sections/seer/seerSecondaryNavigation.spec.tsx +++ b/static/app/views/navigation/secondary/sections/seer/seerSecondaryNavigation.spec.tsx @@ -2,6 +2,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, within} from 'sentry-test/reactTestingLibrary'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {Navigation} from 'sentry/views/navigation'; import {PrimaryNavigationContextProvider} from 'sentry/views/navigation/primaryNavigationContext'; @@ -104,7 +105,10 @@ describe('SeerSecondaryNavigation', () => { it.each([ ['investigations is disabled', {features: [], openMembership: true}], - ['membership is closed', {features: ['investigations'], openMembership: false}], + [ + 'membership is closed', + {features: ['investigations'] satisfies FeatureName[], openMembership: false}, + ], ])('renders no Seer sidebar content when %s', (_, orgProps) => { renderNavigation( '/organizations/org-slug/seer/investigations/', diff --git a/static/app/views/organizationStats/index.spec.tsx b/static/app/views/organizationStats/index.spec.tsx index 2249416a3a09..5792a42e448a 100644 --- a/static/app/views/organizationStats/index.spec.tsx +++ b/static/app/views/organizationStats/index.spec.tsx @@ -11,6 +11,7 @@ import {ConfigStore} from 'sentry/stores/configStore'; import {OrganizationStore} from 'sentry/stores/organizationStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {PageFilters} from 'sentry/types/core'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import OrganizationStats, {PAGE_QUERY_PARAMS} from 'sentry/views/organizationStats'; import {ChartDataTransform} from './usageChart'; @@ -389,7 +390,10 @@ describe('OrganizationStats', () => { it('renders legacy organization stats without appropriate flags', async () => { const selectedProject = [1]; - for (const features of [['team-insights'], ['team-insights']]) { + for (const features of [ + ['team-insights'], + ['team-insights'], + ] satisfies OrganizationFeature[][]) { const newOrg = OrganizationFixture({features}); render(, { diff --git a/static/app/views/performance/table.spec.tsx b/static/app/views/performance/table.spec.tsx index 32b8cfcf94e2..ea0bcef30b5e 100644 --- a/static/app/views/performance/table.spec.tsx +++ b/static/app/views/performance/table.spec.tsx @@ -5,15 +5,16 @@ import {initializeData as _initializeData} from 'sentry-test/performance/initial import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {EventView} from 'sentry/utils/discover/eventView'; import {OrganizationContext} from 'sentry/utils/organizationContext'; import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {Table} from 'sentry/views/performance/table'; -const FEATURES = ['performance-view']; +const FEATURES: OrganizationFeature[] = ['performance-view']; -const initializeData = (settings = {}, features: string[] = []) => { +const initializeData = (settings = {}, features: OrganizationFeature[] = []) => { const projects = [ ProjectFixture({id: '1', slug: '1'}), ProjectFixture({id: '2', slug: '2'}), diff --git a/static/app/views/performance/transactionSummary/header.spec.tsx b/static/app/views/performance/transactionSummary/header.spec.tsx index a4e9136a6ab5..19b6e28ef355 100644 --- a/static/app/views/performance/transactionSummary/header.spec.tsx +++ b/static/app/views/performance/transactionSummary/header.spec.tsx @@ -7,6 +7,7 @@ import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {TeamStore} from 'sentry/stores/teamStore'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {PlatformKey} from 'sentry/types/platform'; import {EventView} from 'sentry/utils/discover/eventView'; import {TopBar} from 'sentry/views/navigation/topBar'; @@ -16,7 +17,7 @@ import {Tab} from 'sentry/views/performance/transactionSummary/tabs'; const TRANSACTION_NAME = 'transaction_name'; type InitialOpts = { - features?: string[]; + features?: OrganizationFeature[]; platform?: PlatformKey; }; @@ -137,7 +138,9 @@ describe('Performance > Transaction Summary Header', () => { url: '/organizations/org-slug/project-transaction-threshold-override/', body: {threshold: '800', metric: 'lcp'}, }); - renderHeader(initializeData({features: ['insights-modules-use-eap']})); + renderHeader( + initializeData({features: ['insights-modules-use-eap'] satisfies FeatureName[]}) + ); await userEvent.click( await screen.findByRole('button', {name: 'Transaction Actions'}) diff --git a/static/app/views/performance/transactionSummary/transactionEvents/eventsTable.spec.tsx b/static/app/views/performance/transactionSummary/transactionEvents/eventsTable.spec.tsx index 93d82ff622da..ceec0affced5 100644 --- a/static/app/views/performance/transactionSummary/transactionEvents/eventsTable.spec.tsx +++ b/static/app/views/performance/transactionSummary/transactionEvents/eventsTable.spec.tsx @@ -5,6 +5,7 @@ import {initializeOrg} from 'sentry-test/initializeOrg'; import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestingLibrary'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {EventView} from 'sentry/utils/discover/eventView'; import {SPAN_OP_RELATIVE_BREAKDOWN_FIELD} from 'sentry/utils/discover/fields'; import {EventsTable} from 'sentry/views/performance/transactionSummary/transactionEvents/eventsTable'; @@ -14,11 +15,15 @@ import { } from 'sentry/views/performance/transactionSummary/transactionEvents/testUtils'; type Data = { - features?: string[]; + features?: OrganizationFeature[]; }; function initializeData({features: additionalFeatures = []}: Data = {}) { - const features = ['discover-basic', 'performance-view', ...additionalFeatures]; + const features = [ + 'discover-basic', + 'performance-view', + ...additionalFeatures, + ] satisfies FeatureName[]; const organization = OrganizationFixture({ features, }); diff --git a/static/app/views/performance/transactionSummary/transactionReplays/index.spec.tsx b/static/app/views/performance/transactionSummary/transactionReplays/index.spec.tsx index cb9187a42c8e..2b611777b5f2 100644 --- a/static/app/views/performance/transactionSummary/transactionReplays/index.spec.tsx +++ b/static/app/views/performance/transactionSummary/transactionReplays/index.spec.tsx @@ -8,6 +8,7 @@ import {resetMockDate, setMockDate} from 'sentry-test/utils'; import {ConfigStore} from 'sentry/stores/configStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import { SPAN_OP_BREAKDOWN_FIELDS, SPAN_OP_RELATIVE_BREAKDOWN_FIELD, @@ -22,7 +23,7 @@ type InitializeOrgProps = { query?: Record; }; organizationProps?: { - features?: string[]; + features?: OrganizationFeature[]; hasGranularReplayPermissions?: boolean; replayAccessMembers?: number[]; }; @@ -33,7 +34,9 @@ const mockReplaysUrl = '/organizations/org-slug/replays/'; const renderComponent = ({ location, - organizationProps = {features: ['performance-view', 'session-replay']}, + organizationProps = { + features: ['performance-view', 'session-replay'] satisfies FeatureName[], + }, }: InitializeOrgProps = {}) => { const {organization, projects} = initializeOrg({ organization: { @@ -266,7 +269,7 @@ describe('TransactionReplays', () => { it("should show a message when the organization doesn't have access to the replay feature", async () => { renderComponent({ organizationProps: { - features: ['performance-view'], + features: ['performance-view'] satisfies FeatureName[], }, }); @@ -280,7 +283,7 @@ describe('TransactionReplays', () => { it('should hide replay content when user does not have granular replay permissions', async () => { renderComponent({ organizationProps: { - features: ['performance-view', 'session-replay'], + features: ['performance-view', 'session-replay'] satisfies FeatureName[], hasGranularReplayPermissions: true, replayAccessMembers: [999], // User ID 1 is not in this list }, diff --git a/static/app/views/seerExplorer/components/seerExplorerHeader.spec.tsx b/static/app/views/seerExplorer/components/seerExplorerHeader.spec.tsx index 22c28b7c4c67..ba386fa2e392 100644 --- a/static/app/views/seerExplorer/components/seerExplorerHeader.spec.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerHeader.spec.tsx @@ -4,12 +4,13 @@ import {UserFixture} from 'sentry-fixture/user'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import {ConfigStore} from 'sentry/stores/configStore'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {SeerExplorerHeader} from 'sentry/views/seerExplorer/components/seerExplorerHeader'; import {SeerExplorerSessionsProvider} from 'sentry/views/seerExplorer/seerExplorerSessionContext'; -const BASE_FEATURES = ['seer-explorer']; +const BASE_FEATURES = ['seer-explorer'] satisfies FeatureName[]; -function orgWith(...extraFeatures: string[]) { +function orgWith(...extraFeatures: OrganizationFeature[]) { return OrganizationFixture({ openMembership: true, hideAiFeatures: false, diff --git a/static/app/views/seerExplorer/components/sidebar/seerExplorerSidebarLayout.spec.tsx b/static/app/views/seerExplorer/components/sidebar/seerExplorerSidebarLayout.spec.tsx index 54176d9e91b7..633488d92e09 100644 --- a/static/app/views/seerExplorer/components/sidebar/seerExplorerSidebarLayout.spec.tsx +++ b/static/app/views/seerExplorer/components/sidebar/seerExplorerSidebarLayout.spec.tsx @@ -8,6 +8,7 @@ import * as drawerModule from '@sentry/scraps/drawer'; import {GlobalDrawer, useDrawer} from '@sentry/scraps/drawer'; import {PictureInPictureProvider} from '@sentry/scraps/pictureInPicture'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import * as analytics from 'sentry/utils/analytics'; import {sessionStorageWrapper} from 'sentry/utils/sessionStorage'; import * as useDimensionsModule from 'sentry/utils/useDimensions'; @@ -21,7 +22,7 @@ import {useSeerExplorerContext} from 'sentry/views/seerExplorer/useSeerExplorerC const POSITION_KEY = 'seer-explorer-sidebar-position'; -const seerFeatures = ['seer-explorer']; +const seerFeatures = ['seer-explorer'] satisfies FeatureName[]; const defaultHookReturn: ReturnType = { sessionData: null, diff --git a/static/app/views/settings/organizationAuth/organizationAuthList.spec.tsx b/static/app/views/settings/organizationAuth/organizationAuthList.spec.tsx index fdffe3ff12d5..3cc9db5c76ce 100644 --- a/static/app/views/settings/organizationAuth/organizationAuthList.spec.tsx +++ b/static/app/views/settings/organizationAuth/organizationAuthList.spec.tsx @@ -3,6 +3,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen} from 'sentry-test/reactTestingLibrary'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {OrganizationAuthList} from 'sentry/views/settings/organizationAuth/organizationAuthList'; describe('OrganizationAuthList', () => { @@ -38,8 +39,8 @@ describe('OrganizationAuthList', () => { describe('with 2fa warning', () => { const require2fa = {require2FA: true}; - const withSSO = {features: ['sso-basic']}; - const withSAML = {features: ['sso-saml2']}; + const withSSO = {features: ['sso-basic'] satisfies FeatureName[]}; + const withSAML = {features: ['sso-saml2'] satisfies FeatureName[]}; it('renders', () => { const organization = OrganizationFixture({...require2fa, ...withSSO}); diff --git a/static/app/views/settings/organizationDataForwarding/util/types.ts b/static/app/views/settings/organizationDataForwarding/util/types.ts index 348bef281e80..ce2d3d3cec96 100644 --- a/static/app/views/settings/organizationDataForwarding/util/types.ts +++ b/static/app/views/settings/organizationDataForwarding/util/types.ts @@ -1,6 +1,7 @@ +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {AvatarProject} from 'sentry/types/project'; -export const DATA_FORWARDING_FEATURES = ['organizations:data-forwarding']; +export const DATA_FORWARDING_FEATURES: FeatureName[] = ['organizations:data-forwarding']; export const DATA_FORWARDING_DOCS_URL = 'https://docs.sentry.io/organization/integrations/data-forwarding/'; diff --git a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx index 2257f7dfec84..de1ff5cec22f 100644 --- a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx +++ b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx @@ -259,7 +259,7 @@ describe('OrganizationSettingsForm', () => { { organization: { ...organization, - features: ['autofix'], + features: [], }, } ); diff --git a/static/app/views/settings/organizationIntegrations/configureIntegration.spec.tsx b/static/app/views/settings/organizationIntegrations/configureIntegration.spec.tsx index 974d503edcbe..286c6714abdc 100644 --- a/static/app/views/settings/organizationIntegrations/configureIntegration.spec.tsx +++ b/static/app/views/settings/organizationIntegrations/configureIntegration.spec.tsx @@ -12,6 +12,7 @@ import { } from 'sentry-test/reactTestingLibrary'; import {selectEvent} from 'sentry-test/selectEvent'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type { IntegrationProvider, OrganizationIntegration, @@ -405,7 +406,7 @@ describe('ConfigureIntegration mapping removals', () => { configData = {sync_status_forward: mappings}, }: { configData?: Record; - features?: string[]; + features?: OrganizationFeature[]; providerKey?: string; } = {}) { const organization = OrganizationFixture({ @@ -480,7 +481,9 @@ describe('ConfigureIntegration mapping removals', () => { }); it('appends a removal entry for a removed mapping', async () => { - const postRequest = setup({features: ['jira-explicit-mapping-removals']}); + const postRequest = setup({ + features: ['jira-explicit-mapping-removals'] satisfies FeatureName[], + }); await deleteSecondMapping(); @@ -502,7 +505,7 @@ describe('ConfigureIntegration mapping removals', () => { it('does not have explicit removals for a non-Jira provider', async () => { const postRequest = setup({ providerKey: 'github', - features: ['jira-explicit-mapping-removals'], + features: ['jira-explicit-mapping-removals'] satisfies FeatureName[], }); await deleteSecondMapping(); @@ -518,7 +521,9 @@ describe('ConfigureIntegration mapping removals', () => { }); it('sends all current mappings as objects when updating a mapping', async () => { - const postRequest = setup({features: ['jira-explicit-mapping-removals']}); + const postRequest = setup({ + features: ['jira-explicit-mapping-removals'] satisfies FeatureName[], + }); await selectEvent.select(await screen.findByText('Open'), 'To Do'); @@ -539,7 +544,7 @@ describe('ConfigureIntegration mapping removals', () => { it('does not add removals when previous mapping data is missing', async () => { const postRequest = setup({ - features: ['jira-explicit-mapping-removals'], + features: ['jira-explicit-mapping-removals'] satisfies FeatureName[], configData: {}, }); diff --git a/static/app/views/settings/organizationIntegrations/sentryAppDetailedView.spec.tsx b/static/app/views/settings/organizationIntegrations/sentryAppDetailedView.spec.tsx index 3563cfa1dc0b..d72d425a7e61 100644 --- a/static/app/views/settings/organizationIntegrations/sentryAppDetailedView.spec.tsx +++ b/static/app/views/settings/organizationIntegrations/sentryAppDetailedView.spec.tsx @@ -12,7 +12,7 @@ import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; import SentryAppDetailedView from 'sentry/views/settings/organizationIntegrations/sentryAppDetailedView'; describe('SentryAppDetailedView', () => { - const organization = OrganizationFixture({features: ['events']}); + const organization = OrganizationFixture({features: []}); afterEach(() => { MockApiClient.clearMockResponses(); diff --git a/static/app/views/settings/project/projectFilters/customFilterModal.tsx b/static/app/views/settings/project/projectFilters/customFilterModal.tsx index 4b096b88edf6..84050a542bc7 100644 --- a/static/app/views/settings/project/projectFilters/customFilterModal.tsx +++ b/static/app/views/settings/project/projectFilters/customFilterModal.tsx @@ -35,6 +35,7 @@ import { import {getKeyName} from 'sentry/components/searchSyntax/utils'; import {android, gaming, sourceMaps} from 'sentry/data/platformCategories'; import {t, tct} from 'sentry/locale'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {TagCollection} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import type {PlatformKey} from 'sentry/types/platform'; @@ -139,7 +140,7 @@ type DataTypeSpec = { // Ingestion feature the org needs before the API accepts a filter on this data // type. Offering a data type without it lets the user build a filter the API // rejects on save, so mirror the gating here. - feature?: string; + feature?: OrganizationFeature; // Shown in the filter table, whose data type column already says "Data Type". tableLabel?: string; }; diff --git a/static/app/views/settings/project/projectFilters/index.spec.tsx b/static/app/views/settings/project/projectFilters/index.spec.tsx index 5ac681dd16de..ccda56fd0dad 100644 --- a/static/app/views/settings/project/projectFilters/index.spec.tsx +++ b/static/app/views/settings/project/projectFilters/index.spec.tsx @@ -17,6 +17,7 @@ import { import {GlobalModal} from '@sentry/scraps/modal'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import ProjectFilters from 'sentry/views/settings/project/projectFilters'; // The custom filter modal saves through the query client of the tree it renders @@ -379,7 +380,12 @@ describe('ProjectFilters', () => { it('can cancel custom filter changes', async () => { render(, { organization, - outletContext: {project: {...project, features: ['custom-inbound-filters']}}, + outletContext: { + project: { + ...project, + features: ['custom-inbound-filters'] satisfies FeatureName[], + }, + }, initialRouterConfig, }); @@ -412,7 +418,7 @@ describe('ProjectFilters', () => { outletContext: { project: { ...project, - features: ['custom-inbound-filters'], + features: ['custom-inbound-filters'] satisfies FeatureName[], }, }, initialRouterConfig, @@ -1710,7 +1716,7 @@ describe('ProjectFilters', () => { outletContext: { project: { ...project, - features: ['custom-inbound-filters'], + features: ['custom-inbound-filters'] satisfies FeatureName[], options: { 'filters:error_messages': 'test', }, @@ -1731,7 +1737,7 @@ describe('ProjectFilters', () => { ...project, features: ['discard-groups'], }); - const discardOrg = OrganizationFixture({access: [], features: ['discard-groups']}); + const discardOrg = OrganizationFixture({access: [], features: []}); render(, { organization: discardOrg, diff --git a/static/app/views/settings/projectDebugFiles/sources/customRepositories/index.spec.tsx b/static/app/views/settings/projectDebugFiles/sources/customRepositories/index.spec.tsx index c59b952771fb..a9b637e8ad1d 100644 --- a/static/app/views/settings/projectDebugFiles/sources/customRepositories/index.spec.tsx +++ b/static/app/views/settings/projectDebugFiles/sources/customRepositories/index.spec.tsx @@ -10,6 +10,7 @@ import {GlobalModal} from '@sentry/scraps/modal'; import {DEBUG_SOURCE_TYPES} from 'sentry/data/debugFileSources'; import type {CustomRepo, CustomRepoHttp} from 'sentry/types/debugFiles'; import {CustomRepoType} from 'sentry/types/debugFiles'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {CustomRepositories} from 'sentry/views/settings/projectDebugFiles/sources/customRepositories'; function TestComponent({ @@ -62,7 +63,10 @@ describe('Custom Repositories', () => { it('renders with custom-symbol-sources feature enabled', async () => { const props = getProps(); - const newOrganization = {...props.organization, features: ['custom-symbol-sources']}; + const newOrganization = { + ...props.organization, + features: ['custom-symbol-sources'] satisfies FeatureName[], + }; const {rerender} = render( diff --git a/static/gsApp/components/features/disabledAlertsPage.tsx b/static/gsApp/components/features/disabledAlertsPage.tsx index dfddd6894c76..33e5bb989f84 100644 --- a/static/gsApp/components/features/disabledAlertsPage.tsx +++ b/static/gsApp/components/features/disabledAlertsPage.tsx @@ -5,6 +5,7 @@ import {IconBusiness} from '@sentry/icons/business'; import {List} from 'sentry/components/list'; import {ListItem} from 'sentry/components/list/listItem'; import {t, tct} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import {withSubscription} from 'getsentry/components/withSubscription'; @@ -16,7 +17,7 @@ import PageUpsellOverlay from './pageUpsellOverlay'; import PlanFeature from './planFeature'; type Props = React.PropsWithChildren<{ - features: string[]; + features: FeatureName[]; organization: Organization; subscription: Subscription; }>; diff --git a/static/gsApp/components/features/disabledAuthProvider.tsx b/static/gsApp/components/features/disabledAuthProvider.tsx index 860e9f7e6996..00c7d6007a7a 100644 --- a/static/gsApp/components/features/disabledAuthProvider.tsx +++ b/static/gsApp/components/features/disabledAuthProvider.tsx @@ -5,6 +5,7 @@ import {Button} from '@sentry/scraps/button'; import type {ChildrenRenderFn} from 'sentry/components/acl/feature'; import {t} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import {openUpsellModal} from 'getsentry/actionCreators/modal'; @@ -20,7 +21,7 @@ type ChildRenderFunction = (options: ChildRenderProps) => React.ReactNode; type Props = { children: React.ReactNode | ChildRenderFunction; - features: string[]; + features: FeatureName[]; hasFeature: boolean; organization: Organization; }; diff --git a/static/gsApp/components/features/disabledCustomInboundFilters.tsx b/static/gsApp/components/features/disabledCustomInboundFilters.tsx index 5a093b00811a..877118a1c8cf 100644 --- a/static/gsApp/components/features/disabledCustomInboundFilters.tsx +++ b/static/gsApp/components/features/disabledCustomInboundFilters.tsx @@ -4,6 +4,7 @@ import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; import {t, tct} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import type {Overrides} from 'sentry/types/overrides'; @@ -12,7 +13,7 @@ import PlanFeature from 'getsentry/components/features/planFeature'; import {displayPlanName} from 'getsentry/utils/billing'; type Props = { - features: string[]; + features: FeatureName[]; organization: Organization; }; diff --git a/static/gsApp/components/features/disabledDashboardPage.tsx b/static/gsApp/components/features/disabledDashboardPage.tsx index 5ef28eb50710..b7f06cc0580a 100644 --- a/static/gsApp/components/features/disabledDashboardPage.tsx +++ b/static/gsApp/components/features/disabledDashboardPage.tsx @@ -5,6 +5,7 @@ import {IconBusiness} from '@sentry/icons/business'; import {List} from 'sentry/components/list'; import {ListItem} from 'sentry/components/list/listItem'; import {t, tct} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import {trackGetsentryAnalytics} from 'getsentry/utils/trackGetsentryAnalytics'; @@ -13,7 +14,7 @@ import {DashboardBackground} from './illustrations/dashboardsBackground'; import PageUpsellOverlay from './pageUpsellOverlay'; type Props = React.PropsWithChildren<{ - features: string[]; + features: FeatureName[]; organization: Organization; }>; diff --git a/static/gsApp/components/features/disabledDataForwarding.tsx b/static/gsApp/components/features/disabledDataForwarding.tsx index 55f6fec867c2..91884fc4c041 100644 --- a/static/gsApp/components/features/disabledDataForwarding.tsx +++ b/static/gsApp/components/features/disabledDataForwarding.tsx @@ -7,6 +7,7 @@ import {Grid} from '@sentry/scraps/layout'; import {EmptyMessage} from 'sentry/components/emptyMessage'; import {Panel} from 'sentry/components/panels/panel'; import {t, tct} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import {openUpsellModal} from 'getsentry/actionCreators/modal'; @@ -15,7 +16,7 @@ import PlanFeature from 'getsentry/components/features/planFeature'; import {displayPlanName} from 'getsentry/utils/billing'; type Props = { - features: string[]; + features: FeatureName[]; organization: Organization; }; diff --git a/static/gsApp/components/features/disabledDiscardGroup.tsx b/static/gsApp/components/features/disabledDiscardGroup.tsx index cb19cf6b6f70..9ae8f5ab7010 100644 --- a/static/gsApp/components/features/disabledDiscardGroup.tsx +++ b/static/gsApp/components/features/disabledDiscardGroup.tsx @@ -7,6 +7,7 @@ import {Grid} from '@sentry/scraps/layout'; import {EmptyMessage} from 'sentry/components/emptyMessage'; import {t, tct} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import {openUpsellModal} from 'getsentry/actionCreators/modal'; @@ -15,7 +16,7 @@ import PlanFeature from 'getsentry/components/features/planFeature'; import {displayPlanName} from 'getsentry/utils/billing'; type Props = { - features: Organization['features']; + features: FeatureName[]; organization: Organization; }; diff --git a/static/gsApp/components/features/disabledDiscover2Page.tsx b/static/gsApp/components/features/disabledDiscover2Page.tsx index 03aa4b61c152..7ab33c36c983 100644 --- a/static/gsApp/components/features/disabledDiscover2Page.tsx +++ b/static/gsApp/components/features/disabledDiscover2Page.tsx @@ -5,6 +5,7 @@ import {IconBusiness} from '@sentry/icons/business'; import {List} from 'sentry/components/list'; import {ListItem} from 'sentry/components/list/listItem'; import {t, tct} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import {displayPlanName} from 'getsentry/utils/billing'; @@ -14,7 +15,7 @@ import PageUpsellOverlay from './pageUpsellOverlay'; import PlanFeature from './planFeature'; type Props = React.PropsWithChildren<{ - features: string[]; + features: FeatureName[]; organization: Organization; }>; diff --git a/static/gsApp/components/features/disabledPerformancePage.tsx b/static/gsApp/components/features/disabledPerformancePage.tsx index e93ac3251da3..fd2436bb5b86 100644 --- a/static/gsApp/components/features/disabledPerformancePage.tsx +++ b/static/gsApp/components/features/disabledPerformancePage.tsx @@ -6,13 +6,14 @@ import {DemoSandboxButton} from 'sentry/components/demoSandboxButton'; import {List} from 'sentry/components/list'; import {ListItem} from 'sentry/components/list/listItem'; import {t, tct} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import {PerformanceBackground} from './illustrations/performanceBackground'; import PageUpsellOverlay from './pageUpsellOverlay'; type Props = React.PropsWithChildren<{ - features: string[]; + features: FeatureName[]; organization: Organization; }>; diff --git a/static/gsApp/components/features/disabledRateLimits.tsx b/static/gsApp/components/features/disabledRateLimits.tsx index aa896e19b56f..fa6cebbe0f12 100644 --- a/static/gsApp/components/features/disabledRateLimits.tsx +++ b/static/gsApp/components/features/disabledRateLimits.tsx @@ -6,6 +6,7 @@ import {Grid} from '@sentry/scraps/layout'; import {PanelAlert} from 'sentry/components/panels/panelAlert'; import {t, tct} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import type {Overrides} from 'sentry/types/overrides'; @@ -15,7 +16,7 @@ import PlanFeature from 'getsentry/components/features/planFeature'; import {displayPlanName} from 'getsentry/utils/billing'; type Props = { - features: string[]; + features: FeatureName[]; organization: Organization; }; diff --git a/static/gsApp/components/features/pageUpsellOverlay.tsx b/static/gsApp/components/features/pageUpsellOverlay.tsx index f194864dacd5..8f84b38de2f3 100644 --- a/static/gsApp/components/features/pageUpsellOverlay.tsx +++ b/static/gsApp/components/features/pageUpsellOverlay.tsx @@ -6,6 +6,7 @@ import {Grid, type GridProps} from '@sentry/scraps/layout'; import {PageOverlay} from 'sentry/components/pageOverlay'; import {t, tct} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import {openUpsellModal} from 'getsentry/actionCreators/modal'; @@ -18,7 +19,7 @@ import PlanFeature from './planFeature'; type Props = Omit, 'text'> & { description: React.ReactNode; - features: string[]; + features: FeatureName[]; name: string; organization: Organization; requiredPlan: React.ReactNode; diff --git a/static/gsApp/components/features/performanceNewProjectPrompt.tsx b/static/gsApp/components/features/performanceNewProjectPrompt.tsx index c2cf892fd5ae..392ae898a2b5 100644 --- a/static/gsApp/components/features/performanceNewProjectPrompt.tsx +++ b/static/gsApp/components/features/performanceNewProjectPrompt.tsx @@ -5,12 +5,13 @@ import {Button} from '@sentry/scraps/button'; import {Container, Flex} from '@sentry/scraps/layout'; import {t} from 'sentry/locale'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import {openUpsellModal} from 'getsentry/actionCreators/modal'; type Props = React.PropsWithChildren<{ - features: Organization['features']; + features: FeatureName[]; organization: Organization; }>; diff --git a/static/gsApp/components/features/planFeature.tsx b/static/gsApp/components/features/planFeature.tsx index 5149d6e8019b..e5730d4c5b93 100644 --- a/static/gsApp/components/features/planFeature.tsx +++ b/static/gsApp/components/features/planFeature.tsx @@ -76,10 +76,12 @@ function planSatisfies(plan: Plan, requestedFeatures: string[]) { const rank = ORDERED_PLAN_TYPES.indexOf(type); - return requestedFeatures.map(descopeFeatureName).every(feature => { - const required = UPSELL_MINIMUM_PLAN_TYPE[feature]; - return required !== undefined && rank >= ORDERED_PLAN_TYPES.indexOf(required); - }); + return requestedFeatures + .map(feature => descopeFeatureName(feature)) + .every(feature => { + const required = UPSELL_MINIMUM_PLAN_TYPE[feature]; + return required !== undefined && rank >= ORDERED_PLAN_TYPES.indexOf(required); + }); } type RenderProps = { diff --git a/static/gsApp/components/labelWithPowerIcon.tsx b/static/gsApp/components/labelWithPowerIcon.tsx index 3e9b68b6517a..dd009383f96d 100644 --- a/static/gsApp/components/labelWithPowerIcon.tsx +++ b/static/gsApp/components/labelWithPowerIcon.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import {IconBusiness} from '@sentry/icons/business'; import Feature from 'sentry/components/acl/feature'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import PowerFeatureHovercard from 'getsentry/components/powerFeatureHovercard'; import {withSubscription} from 'getsentry/components/withSubscription'; @@ -28,7 +29,11 @@ const TABLE_ITEMS = [TEAM_ROLES]; * features: [...] * } */ -const POWER_FEATURE_CONFIG = [ +const POWER_FEATURE_CONFIG: Array<{ + features: FeatureName[]; + id: string; + partial: boolean; +}> = [ { id: SSO, features: ['sso-saml2'], diff --git a/static/gsApp/components/powerFeatureHovercard.tsx b/static/gsApp/components/powerFeatureHovercard.tsx index 0d8bbfdef0a0..cbc75b7dbf25 100644 --- a/static/gsApp/components/powerFeatureHovercard.tsx +++ b/static/gsApp/components/powerFeatureHovercard.tsx @@ -6,7 +6,7 @@ import {Stack} from '@sentry/scraps/layout'; import {Hovercard} from 'sentry/components/hovercard'; import {t} from 'sentry/locale'; -import type {Organization} from 'sentry/types/organization'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {useOrganization} from 'sentry/utils/useOrganization'; import {openUpsellModal} from 'getsentry/actionCreators/modal'; @@ -24,7 +24,7 @@ interface PowerFeatureHovercardProps { * NOTE: If the user has all features the hovercard will NOT be hidden. It * is up to the parent component to decide whether this should be rendered. */ - features: Organization['features']; + features: FeatureName[]; subscription: Subscription; children?: React.ReactNode; diff --git a/static/gsApp/overrides/integrationFeatures.spec.tsx b/static/gsApp/overrides/integrationFeatures.spec.tsx index c500d71176a6..7c2eaf1dcd6a 100644 --- a/static/gsApp/overrides/integrationFeatures.spec.tsx +++ b/static/gsApp/overrides/integrationFeatures.spec.tsx @@ -9,6 +9,7 @@ import {PlanTier} from 'getsentry-test/planTier'; import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; import {ConfigStore} from 'sentry/stores/configStore'; +import {addOrganizationFeaturesHandler} from 'sentry/utils/featureFlags'; import {hookIntegrationFeatures} from 'getsentry/overrides/integrationFeatures'; import {SubscriptionStore} from 'getsentry/stores/subscriptionStore'; @@ -61,8 +62,11 @@ describe('hookIntegrationFeatures', () => { }); it('gates premium only features and requires upgrade with free plan', async () => { - const sub = SubscriptionFixture({organization}); - SubscriptionStore.set(organization.slug, sub); + const instrumentedOrganization = OrganizationFixture(); + const handler = jest.fn(); + addOrganizationFeaturesHandler({organization: instrumentedOrganization, handler}); + const sub = SubscriptionFixture({organization: instrumentedOrganization}); + SubscriptionStore.set(instrumentedOrganization.slug, sub); const features = [ { @@ -78,7 +82,7 @@ describe('hookIntegrationFeatures', () => { const renderCallback = jest.fn(() => ); render( - + {renderCallback} ); @@ -102,6 +106,8 @@ describe('hookIntegrationFeatures', () => { ], }); }); + expect(handler).toHaveBeenCalledWith('integrations-issue-basic', false); + expect(handler).toHaveBeenCalledWith('integrations-event-hooks', false); }); describe('FeatureList and IntegrationFeatures that distinguish free and premium', () => { diff --git a/static/gsApp/overrides/integrationFeatures.tsx b/static/gsApp/overrides/integrationFeatures.tsx index d004ad3e3292..ef35306c23d4 100644 --- a/static/gsApp/overrides/integrationFeatures.tsx +++ b/static/gsApp/overrides/integrationFeatures.tsx @@ -159,13 +159,14 @@ function mapFeatureGroups({ // including the plan. For each feature group it is determined if all // features also have associated organization feature flags, indicating that // the features are enabled. + const organizationFeatures: readonly string[] = organization.features; const gatedFeatureGroups = plans .filter(plan => groupedPlanFeatures[plan.id] !== undefined) .map(plan => ({ plan, features: groupedPlanFeatures[plan.id]!, hasFeatures: !groupedPlanFeatures[plan.id]!.map(f => f.featureGate) - .map(f => organization.features.includes(f)) + .map(f => organizationFeatures.includes(f)) .some(v => !v), })); diff --git a/static/gsApp/overrides/useExperiment.spec.tsx b/static/gsApp/overrides/useExperiment.spec.tsx index ad92753d81f5..4de52198d860 100644 --- a/static/gsApp/overrides/useExperiment.spec.tsx +++ b/static/gsApp/overrides/useExperiment.spec.tsx @@ -4,6 +4,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; import {ConfigStore} from 'sentry/stores/configStore'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import {_resetExposureTracking, useExperiment} from 'getsentry/overrides/useExperiment'; @@ -11,7 +12,7 @@ function TestComponent({ feature, reportExposure, }: { - feature: string; + feature: OrganizationFeature; reportExposure: boolean; }) { const {inExperiment, experimentAssignment} = useExperiment({ @@ -34,10 +35,10 @@ describe('useExperiment (gsApp)', () => { it('returns inExperiment: true when feature is enabled', () => { const org = OrganizationFixture({ - features: ['test-experiment'], - experiments: {'test-experiment': 'active'}, + features: ['investigations'], + experiments: {investigations: 'active'}, }); - render(, { + render(, { organization: org, }); expect(screen.getByTestId('in-experiment')).toHaveTextContent('true'); @@ -46,9 +47,9 @@ describe('useExperiment (gsApp)', () => { it('returns inExperiment: false when feature is not enabled', () => { const org = OrganizationFixture({ - experiments: {'test-experiment': 'control'}, + experiments: {investigations: 'control'}, }); - render(, { + render(, { organization: org, }); expect(screen.getByTestId('in-experiment')).toHaveTextContent('false'); @@ -57,9 +58,9 @@ describe('useExperiment (gsApp)', () => { it('returns inExperiment: true with feature flag even without experiments entry', () => { const org = OrganizationFixture({ - features: ['test-experiment'], + features: ['investigations'], }); - render(, { + render(, { organization: org, }); expect(screen.getByTestId('in-experiment')).toHaveTextContent('true'); @@ -68,7 +69,7 @@ describe('useExperiment (gsApp)', () => { it('returns control when experiment is not present', () => { const org = OrganizationFixture({experiments: {}}); - render(, { + render(, { organization: org, }); expect(screen.getByTestId('in-experiment')).toHaveTextContent('false'); @@ -77,8 +78,8 @@ describe('useExperiment (gsApp)', () => { it('posts exposure on mount when reportExposure is true and experiments entry exists', async () => { const org = OrganizationFixture({ - features: ['test-experiment'], - experiments: {'test-experiment': 'active'}, + features: ['investigations'], + experiments: {investigations: 'active'}, }); const mockExposure = MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/experiment-exposure/`, @@ -86,7 +87,7 @@ describe('useExperiment (gsApp)', () => { statusCode: 204, }); - render(, { + render(, { organization: org, }); @@ -95,7 +96,10 @@ describe('useExperiment (gsApp)', () => { `/organizations/${org.slug}/experiment-exposure/`, expect.objectContaining({ method: 'POST', - data: {experimentName: 'test-experiment', assignment: 'active'}, + data: { + experimentName: 'investigations', + assignment: 'active', + }, }) ); }); @@ -103,8 +107,8 @@ describe('useExperiment (gsApp)', () => { it('sets the Amplitude experiment group property on exposure', async () => { const org = OrganizationFixture({ - features: ['test-experiment'], - experiments: {'test-experiment': 'active'}, + features: ['investigations'], + experiments: {investigations: 'active'}, }); MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/experiment-exposure/`, @@ -112,7 +116,7 @@ describe('useExperiment (gsApp)', () => { statusCode: 204, }); - render(, { + render(, { organization: org, }); @@ -124,7 +128,7 @@ describe('useExperiment (gsApp)', () => { // transform matches getsentry/experiments/tasks.py. const identifyInstance = jest.mocked(Amplitude.Identify).mock.results[0]!.value; expect(identifyInstance.set).toHaveBeenCalledWith( - 'experiment_test_experiment', + 'experiment_investigations', 'active' ); expect(Amplitude.groupIdentify).toHaveBeenCalledWith( @@ -136,8 +140,8 @@ describe('useExperiment (gsApp)', () => { it('does not set the Amplitude group property when reportExposure is false', () => { const org = OrganizationFixture({ - features: ['test-experiment'], - experiments: {'test-experiment': 'active'}, + features: ['investigations'], + experiments: {investigations: 'active'}, }); MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/experiment-exposure/`, @@ -145,7 +149,7 @@ describe('useExperiment (gsApp)', () => { statusCode: 204, }); - render(, { + render(, { organization: org, }); @@ -155,8 +159,8 @@ describe('useExperiment (gsApp)', () => { it('does not set the Amplitude group property when analytics are disabled', () => { ConfigStore.set('enableAnalytics', false); const org = OrganizationFixture({ - features: ['test-experiment'], - experiments: {'test-experiment': 'active'}, + features: ['investigations'], + experiments: {investigations: 'active'}, }); MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/experiment-exposure/`, @@ -164,7 +168,7 @@ describe('useExperiment (gsApp)', () => { statusCode: 204, }); - render(, { + render(, { organization: org, }); @@ -177,7 +181,7 @@ describe('useExperiment (gsApp)', () => { // The BE only fires auto-exposure for flags with experiment_mode set, so we // match that behavior here and skip both the Amplitude write and the POST. const org = OrganizationFixture({ - features: ['not-an-experiment'], + features: ['dashboards-basic'], experiments: {}, }); const mockExposure = MockApiClient.addMockResponse({ @@ -186,7 +190,7 @@ describe('useExperiment (gsApp)', () => { statusCode: 204, }); - render(, { + render(, { organization: org, }); @@ -199,8 +203,8 @@ describe('useExperiment (gsApp)', () => { it('does not post exposure when reportExposure is false', () => { const org = OrganizationFixture({ - features: ['test-experiment'], - experiments: {'test-experiment': 'active'}, + features: ['investigations'], + experiments: {investigations: 'active'}, }); const mockExposure = MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/experiment-exposure/`, @@ -208,7 +212,7 @@ describe('useExperiment (gsApp)', () => { statusCode: 204, }); - render(, { + render(, { organization: org, }); @@ -217,8 +221,8 @@ describe('useExperiment (gsApp)', () => { it('reports exposure when reportExposure changes from false to true', async () => { const org = OrganizationFixture({ - features: ['test-experiment'], - experiments: {'test-experiment': 'active'}, + features: ['investigations'], + experiments: {investigations: 'active'}, }); const mockExposure = MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/experiment-exposure/`, @@ -227,21 +231,21 @@ describe('useExperiment (gsApp)', () => { }); const {rerender} = render( - , + , {organization: org} ); expect(mockExposure).not.toHaveBeenCalled(); - rerender(); + rerender(); await waitFor(() => expect(mockExposure).toHaveBeenCalledTimes(1)); }); it('deduplicates exposure reports across remounts', async () => { const org = OrganizationFixture({ - features: ['test-experiment'], - experiments: {'test-experiment': 'active'}, + features: ['investigations'], + experiments: {investigations: 'active'}, }); const mockExposure = MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/experiment-exposure/`, @@ -249,7 +253,7 @@ describe('useExperiment (gsApp)', () => { statusCode: 204, }); - const {unmount} = render(, { + const {unmount} = render(, { organization: org, }); @@ -259,7 +263,7 @@ describe('useExperiment (gsApp)', () => { unmount(); - render(, { + render(, { organization: org, }); @@ -268,7 +272,7 @@ describe('useExperiment (gsApp)', () => { it('re-reports exposure when assignment changes', async () => { const org = OrganizationFixture({ - experiments: {'test-experiment': 'control'}, + experiments: {investigations: 'control'}, }); const mockExposure = MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/experiment-exposure/`, @@ -276,7 +280,7 @@ describe('useExperiment (gsApp)', () => { statusCode: 204, }); - const {unmount} = render(, { + const {unmount} = render(, { organization: org, }); @@ -285,11 +289,11 @@ describe('useExperiment (gsApp)', () => { unmount(); const updatedOrg = OrganizationFixture({ - features: ['test-experiment'], - experiments: {'test-experiment': 'active'}, + features: ['investigations'], + experiments: {investigations: 'active'}, }); - render(, { + render(, { organization: updatedOrg, }); diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index 71e6e03fd1ed..2318d626cbd2 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -2,6 +2,7 @@ import type {StripeConstructor} from '@stripe/stripe-js'; import type {DATA_CATEGORY_INFO} from 'sentry/constants'; import type {DataCategory, DataCategoryInfo} from 'sentry/types/core'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; declare global { interface Window { @@ -1008,7 +1009,7 @@ export interface BilledDataCategoryInfo extends DataCategoryInfo { * - `true`: Graduated - trials always available (no flag check needed) * - `null/undefined`: Not an admin-only trial category */ - adminOnlyProductTrialFeature?: string | boolean | null; + adminOnlyProductTrialFeature?: OrganizationFeature | boolean | null; /** * The shortened form of the singular unit name (ie. 'error', 'hour', 'monitor'). */ diff --git a/static/gsApp/views/spendAllocations/index.spec.tsx b/static/gsApp/views/spendAllocations/index.spec.tsx index f78b37e50f38..49326deffcdc 100644 --- a/static/gsApp/views/spendAllocations/index.spec.tsx +++ b/static/gsApp/views/spendAllocations/index.spec.tsx @@ -18,6 +18,7 @@ import {selectEvent} from 'sentry-test/selectEvent'; import * as indicators from 'sentry/actionCreators/indicator'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {FeatureName} from 'sentry/types/featureFlags.generated'; import {SubscriptionStore} from 'getsentry/stores/subscriptionStore'; import {SpendAllocationsRoot} from 'getsentry/views/spendAllocations/index'; @@ -33,7 +34,7 @@ describe('SpendAllocations feature enable flow', () => { beforeEach(() => { organization = initializeOrg({ organization: { - features: ['spend-allocations'], + features: ['spend-allocations'] satisfies FeatureName[], }, }).organization; subscription = SubscriptionFixture({ @@ -152,7 +153,7 @@ describe('enabled Spend Allocations page', () => { beforeEach(() => { organization = initializeOrg({ organization: { - features: ['spend-allocations'], + features: ['spend-allocations'] satisfies FeatureName[], }, }).organization; organization.access = [ @@ -324,7 +325,7 @@ describe('enabled Spend Allocations page without root', () => { beforeEach(() => { organization = initializeOrg({ organization: { - features: ['spend-allocations'], + features: ['spend-allocations'] satisfies FeatureName[], }, }).organization; organization.access = [ @@ -432,7 +433,7 @@ describe('POST Create spend allocation', () => { ]; organization = initializeOrg({ organization: { - features: ['spend-allocations'], + features: ['spend-allocations'] satisfies FeatureName[], }, }).organization; subscription = SubscriptionFixture({ @@ -519,7 +520,7 @@ describe('Disable Submit button in Spend Allocation', () => { ]; organization = initializeOrg({ organization: { - features: ['spend-allocations'], + features: ['spend-allocations'] satisfies FeatureName[], }, }).organization; subscription = SubscriptionFixture({ @@ -563,7 +564,7 @@ describe('DELETE spend allocation', () => { beforeEach(() => { organization = initializeOrg({ organization: { - features: ['spend-allocations'], + features: ['spend-allocations'] satisfies FeatureName[], }, }).organization; subscription = SubscriptionFixture({ @@ -642,7 +643,7 @@ describe('PUT edit spend allocation', () => { ]; organization = initializeOrg({ organization: { - features: ['spend-allocations'], + features: ['spend-allocations'] satisfies FeatureName[], }, }).organization; subscription = SubscriptionFixture({ diff --git a/tests/js/fixtures/authProvider.ts b/tests/js/fixtures/authProvider.ts index 9188971606c2..aeed5f3c3890 100644 --- a/tests/js/fixtures/authProvider.ts +++ b/tests/js/fixtures/authProvider.ts @@ -4,7 +4,7 @@ export function AuthProviderFixture(params: Partial = {}): AuthPro return { key: 'auth_provider_key', name: 'auth_provider_name', - requiredFeature: 'auth_provider_required_feature', + requiredFeature: 'organizations:sso-basic', ...params, }; } diff --git a/tests/js/fixtures/log.ts b/tests/js/fixtures/log.ts index e0e963f3b7a6..b0d68a6c40bd 100644 --- a/tests/js/fixtures/log.ts +++ b/tests/js/fixtures/log.ts @@ -5,6 +5,7 @@ import {initializeOrg} from 'sentry-test/initializeOrg'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {PageFilters} from 'sentry/types/core'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import type {EventsMetaType} from 'sentry/utils/discover/eventView'; @@ -77,7 +78,7 @@ export function LogFixtureMeta( interface LogsTestInitOptions { isProjectOnboarded?: boolean; liveRefresh?: boolean; - orgFeatures?: string[]; + orgFeatures?: OrganizationFeature[]; organization?: Partial; ourlogs?: boolean; pageFiltersPeriod?: string; @@ -120,12 +121,12 @@ export function initializeLogsTest({ setupTotalPayloadMock: () => jest.Mock; setupTraceItemsMock: (logFixtures: OurLogsResponseItem[]) => jest.Mock[]; } { - const baseFeatures = ourlogs ? ['ourlogs-enabled'] : []; + const baseFeatures: OrganizationFeature[] = ourlogs ? ['ourlogs-enabled'] : []; const forcedProject = projectOverrides ?? {hasLogs: true}; const {organization, project} = initializeOrg({ organization: { - features: [...baseFeatures, ...orgFeatures], + features: [...baseFeatures, ...orgFeatures] satisfies FeatureName[], ...orgOverrides, }, projects: [forcedProject], diff --git a/tests/js/fixtures/tracemetrics.ts b/tests/js/fixtures/tracemetrics.ts index 21cbeed9d1d4..3b25ec48ada4 100644 --- a/tests/js/fixtures/tracemetrics.ts +++ b/tests/js/fixtures/tracemetrics.ts @@ -5,6 +5,7 @@ import {initializeOrg} from 'sentry-test/initializeOrg'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {PageFilters} from 'sentry/types/core'; +import type {FeatureName, OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import type {EventsMetaType} from 'sentry/utils/discover/eventView'; @@ -84,7 +85,7 @@ function TraceMetricFixtureMeta( interface TraceMetricsTestInitOptions { isProjectOnboarded?: boolean; liveRefresh?: boolean; - orgFeatures?: string[]; + orgFeatures?: OrganizationFeature[]; organization?: Partial; pageFiltersPeriod?: string; project?: Partial; @@ -129,12 +130,14 @@ export function initializeTraceMetricsTest({ setupPageFilters: () => void; setupTraceItemsMock: (metricFixtures: TraceMetricEventsResponseItem[]) => jest.Mock[]; } { - const baseFeatures = tracemetrics ? ['tracemetrics-enabled'] : []; + const baseFeatures: OrganizationFeature[] = tracemetrics + ? ['tracemetrics-enabled'] + : []; const forcedProject = projectOverrides ?? {hasTraceMetrics: true}; const {organization, project} = initializeOrg({ organization: { - features: [...baseFeatures, ...orgFeatures], + features: [...baseFeatures, ...orgFeatures] satisfies FeatureName[], ...orgOverrides, }, projects: [forcedProject], diff --git a/tests/js/sentry-test/performance/initializePerformanceData.ts b/tests/js/sentry-test/performance/initializePerformanceData.ts index 3309fccb92f9..411f044fa411 100644 --- a/tests/js/sentry-test/performance/initializePerformanceData.ts +++ b/tests/js/sentry-test/performance/initializePerformanceData.ts @@ -4,11 +4,12 @@ import {RouterFixture} from 'sentry-fixture/routerFixture'; import {initializeOrg} from 'sentry-test/initializeOrg'; +import type {OrganizationFeature} from 'sentry/types/featureFlags.generated'; import type {Project} from 'sentry/types/project'; import {EventView} from 'sentry/utils/discover/eventView'; export interface InitializeDataSettings { - features?: string[]; + features?: OrganizationFeature[]; project?: any; // TODO(k-fish): Fix this project type. projects?: Project[]; query?: Record;