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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/file-filters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -686,6 +710,7 @@ jobs:
calculate-shards,
cli,
files-changed,
feature-flag-types,
requirements,
migration,
monolith-dbs,
Expand Down
6 changes: 6 additions & 0 deletions static/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
112 changes: 79 additions & 33 deletions static/app/components/acl/feature.spec.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<OrganizationFeature>();
expectTypeOf<'custom-inbound-filters'>().toMatchTypeOf<ProjectFeature>();
expectTypeOf<'organizations:create'>().toMatchTypeOf<GlobalFeature>();
expectTypeOf<'retired-feature'>().not.toMatchTypeOf<OrganizationFeature>();
expectTypeOf<string>().not.toMatchTypeOf<OrganizationFeature>();
expectTypeOf<'custom-inbound-filters'>().not.toMatchTypeOf<OrganizationFeature>();
expectTypeOf<'dashboards-import'>().not.toMatchTypeOf<GlobalFeature>();
expectTypeOf<'retired-feature'>().not.toMatchTypeOf<
React.ComponentProps<typeof Feature>['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<typeof Feature>) {
Expand All @@ -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(<WrappedFeature features={features}>{childrenMock}</WrappedFeature>, {
organization,
Expand All @@ -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(
<WrappedFeature features={features} requireAll={false}>
Expand All @@ -62,23 +88,26 @@ describe('Feature', () => {
});

it('has no features', () => {
render(<WrappedFeature features="org-baz">{childrenMock}</WrappedFeature>, {
organization,
});
render(
<WrappedFeature features="dashboards-import">{childrenMock}</WrappedFeature>,
{
organization,
}
);

expect(childrenMock).toHaveBeenCalledWith({
hasFeature: false,
organization,
project,
features: ['org-baz'],
features: ['dashboards-import'] satisfies FeatureName[],
renderDisabled: false,
});
});

it('calls render function when no features', () => {
const noFeatureRenderer = jest.fn(() => null);
render(
<WrappedFeature features="org-baz" renderDisabled={noFeatureRenderer}>
<WrappedFeature features="dashboards-import" renderDisabled={noFeatureRenderer}>
{childrenMock}
</WrappedFeature>,
{organization}
Expand All @@ -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(
<WrappedFeature organization={customOrg} features="org-bazar">
<WrappedFeature organization={customOrg} features="dashboards-starred">
{childrenMock}
</WrappedFeature>,
{organization}
Expand All @@ -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(
<WrappedFeature project={customProject} features="project-baz">
<WrappedFeature project={customProject} features="discard-groups">
{childrenMock}
</WrappedFeature>,
{organization}
Expand All @@ -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(<WrappedFeature features={features}>{childrenMock}</WrappedFeature>, {
organization,
});
Expand All @@ -149,7 +181,9 @@ describe('Feature', () => {

it('handles features prefixed with org/project', () => {
render(
<WrappedFeature features="organizations:org-bar">{childrenMock}</WrappedFeature>,
<WrappedFeature features="organizations:dashboards-edit">
{childrenMock}
</WrappedFeature>,
{
organization,
}
Expand All @@ -159,19 +193,22 @@ describe('Feature', () => {
hasFeature: true,
organization,
project,
features: ['organizations:org-bar'],
features: ['organizations:dashboards-edit'] satisfies FeatureName[],
renderDisabled: false,
});

render(<WrappedFeature features="projects:bar">{childrenMock}</WrappedFeature>, {
organization,
});
render(
<WrappedFeature features="projects:releases">{childrenMock}</WrappedFeature>,
{
organization,
}
);

expect(childrenMock).toHaveBeenCalledWith({
hasFeature: false,
organization,
project,
features: ['projects:bar'],
features: ['projects:releases'] satisfies FeatureName[],
renderDisabled: false,
});
});
Expand All @@ -194,7 +231,7 @@ describe('Feature', () => {
hasFeature: true,
organization,
project,
features: ['organizations:create'],
features: ['organizations:create'] satisfies FeatureName[],
renderDisabled: false,
});
});
Expand All @@ -203,7 +240,10 @@ describe('Feature', () => {
describe('no children', () => {
it('should display renderDisabled with no feature', () => {
render(
<WrappedFeature features="nope" renderDisabled={() => <span>disabled</span>}>
<WrappedFeature
features="data-forwarding"
renderDisabled={() => <span>disabled</span>}
>
<div>The Child</div>
</WrappedFeature>,
{organization}
Expand All @@ -213,7 +253,10 @@ describe('Feature', () => {

it('should display be empty when on', () => {
render(
<WrappedFeature features="org-bar" renderDisabled={() => <span>disabled</span>}>
<WrappedFeature
features="dashboards-edit"
renderDisabled={() => <span>disabled</span>}
>
<div>The Child</div>
</WrappedFeature>,
{organization}
Expand All @@ -225,7 +268,7 @@ describe('Feature', () => {
describe('as React node', () => {
it('has features', () => {
render(
<WrappedFeature features="org-bar">
<WrappedFeature features="dashboards-edit">
<div>The Child</div>
</WrappedFeature>,
{organization}
Expand All @@ -236,7 +279,7 @@ describe('Feature', () => {

it('has no features', () => {
render(
<WrappedFeature features="org-baz">
<WrappedFeature features="dashboards-import">
<div>The Child</div>
</WrappedFeature>,
{organization}
Expand All @@ -247,7 +290,7 @@ describe('Feature', () => {

it('renders a default disabled component', () => {
render(
<WrappedFeature features="org-baz" renderDisabled>
<WrappedFeature features="dashboards-import" renderDisabled>
<div>The Child</div>
</WrappedFeature>,
{organization}
Expand All @@ -261,7 +304,7 @@ describe('Feature', () => {
const noFeatureRenderer = jest.fn(() => null);
const children = <div>The Child</div>;
render(
<WrappedFeature features="org-baz" renderDisabled={noFeatureRenderer}>
<WrappedFeature features="dashboards-import" renderDisabled={noFeatureRenderer}>
{children}
</WrappedFeature>,
{organization}
Expand All @@ -274,7 +317,7 @@ describe('Feature', () => {
children,
organization,
project,
features: ['org-baz'],
features: ['dashboards-import'] satisfies FeatureName[],
});
});
});
Expand All @@ -290,7 +333,10 @@ describe('Feature', () => {
it('uses overrideName if provided', () => {
const children = <div>The Child</div>;
render(
<WrappedFeature features="org-bazar" overrideName="feature-disabled:sso-basic">
<WrappedFeature
features="dashboards-starred"
overrideName="feature-disabled:sso-basic"
>
{children}
</WrappedFeature>,
{organization}
Expand All @@ -303,7 +349,7 @@ describe('Feature', () => {
children,
organization,
project,
features: ['org-bazar'],
features: ['dashboards-starred'] satisfies FeatureName[],
});
});
});
Expand Down
5 changes: 3 additions & 2 deletions static/app/components/acl/feature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
*/
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading