From 2d5bf7d5f82201e13d0f12263dded20861e65a3d Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 7 Apr 2026 01:20:40 -0700 Subject: [PATCH 01/18] ref(settings): Migrate personal tokens pages to useScrapsForm (#110502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the personal API tokens settings pages (`apiNewToken.tsx` and `apiTokenDetails.tsx`) from the deprecated `ApiForm`/`Form`+`FormModel` system to the new `useScrapsForm` + `fetchMutation` pattern, consistent with the org auth tokens implementation. **What changed:** - `apiNewToken.tsx`: replaces the deprecated `ApiForm` wrapper with `useScrapsForm` + `useMutation`/`fetchMutation`. Permissions remain local `useState` — scopes are derived via `permissionStateToList` at submit time rather than through the old form model's `scopes` field. The disabled `TextareaField` preview becomes a read-only `FieldGroup`. - `apiTokenDetails.tsx`: replaces `Form` + the `useMutateApiToken` hook with `useScrapsForm` + an inline `useMutation`. Read-only fields (token preview, scopes) move from disabled `TextField`/`FieldGroup` to plain `FieldGroup` with a `
`, matching the org tokens pattern. Query cache updates are preserved. - `permissionSelection.tsx`: exports `permissionStateToList` so callers can compute the full hierarchical scope list independently; guards `this.context.form?.setValue` with optional chaining so the component works outside a legacy `FormModel` context tree. - Deletes `useMutateApiToken.tsx` — no remaining consumers after the details page migration. - Expands test coverage: adds success modal appearance, API error handling, initial render state (name/preview/scopes), and fetch error state; renames the misnamed `describe('ApiNewToken')` block in the details spec. Fixes DE-965 Co-Authored-By: Claude --------- Co-authored-by: Claude Co-authored-by: Priscila Oliveira --- static/app/utils/useMutateApiToken.tsx | 86 -------- .../settings/account/apiNewToken.spec.tsx | 60 +++++- .../views/settings/account/apiNewToken.tsx | 192 ++++++++++------- .../settings/account/apiTokenDetails.spec.tsx | 72 ++++--- .../settings/account/apiTokenDetails.tsx | 203 +++++++++++------- .../permissionSelection.tsx | 7 +- 6 files changed, 350 insertions(+), 270 deletions(-) delete mode 100644 static/app/utils/useMutateApiToken.tsx diff --git a/static/app/utils/useMutateApiToken.tsx b/static/app/utils/useMutateApiToken.tsx deleted file mode 100644 index e7ad92b3d46db5..00000000000000 --- a/static/app/utils/useMutateApiToken.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type {InternalAppApiToken} from 'sentry/types/user'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import { - getApiQueryData, - setApiQueryData, - useMutation, - useQueryClient, - type ApiQueryKey, -} from 'sentry/utils/queryClient'; -import type {RequestError} from 'sentry/utils/requestError/requestError'; -import {useApi} from 'sentry/utils/useApi'; - -const API_TOKEN_QUERY_KEY: ApiQueryKey = [getApiUrl('/api-tokens/')]; - -type UpdateTokenQueryVariables = { - name: string; -}; -type FetchApiTokenParameters = { - tokenId: string; -}; -const makeFetchApiTokenKey = ({tokenId}: FetchApiTokenParameters): ApiQueryKey => [ - getApiUrl('/api-tokens/$tokenId/', {path: {tokenId}}), -]; - -interface UseMutateApiTokenProps { - token: InternalAppApiToken; - onError?: (error: RequestError) => void; - onSuccess?: (token: InternalAppApiToken | undefined) => void; -} - -export function useMutateApiToken({token, onSuccess, onError}: UseMutateApiTokenProps) { - const api = useApi(); - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({name}) => - api.requestPromise(`/api-tokens/${token.id}/`, { - method: 'PUT', - data: { - name, - }, - }), - - onSuccess: (_data, {name}) => { - // Update get by id query - let updatedData: InternalAppApiToken | undefined = undefined; - updatedData = setApiQueryData( - queryClient, - makeFetchApiTokenKey({tokenId: token.id}), - (oldData?: InternalAppApiToken) => { - if (!oldData) { - return oldData; - } - - oldData.name = name; - - return oldData; - } - ); - - // Update get list query - if (getApiQueryData(queryClient, API_TOKEN_QUERY_KEY)) { - setApiQueryData( - queryClient, - API_TOKEN_QUERY_KEY, - (oldData?: InternalAppApiToken[]) => { - if (!Array.isArray(oldData)) { - return oldData; - } - - const existingToken = oldData.find(oldToken => oldToken.id === token.id); - - if (existingToken) { - existingToken.name = name; - } - - return oldData; - } - ); - } - return onSuccess?.(updatedData); - }, - onError: error => { - return onError?.(error); - }, - }); -} diff --git a/static/app/views/settings/account/apiNewToken.spec.tsx b/static/app/views/settings/account/apiNewToken.spec.tsx index bc1183d936cfd6..0fbdc6d86b5e94 100644 --- a/static/app/views/settings/account/apiNewToken.spec.tsx +++ b/static/app/views/settings/account/apiNewToken.spec.tsx @@ -1,6 +1,15 @@ -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; +import {ApiTokenFixture} from 'sentry-fixture/apiToken'; + +import { + render, + renderGlobalModal, + screen, + userEvent, + waitFor, +} from 'sentry-test/reactTestingLibrary'; import {selectEvent} from 'sentry-test/selectEvent'; +import * as indicators from 'sentry/actionCreators/indicator'; import ApiNewToken from 'sentry/views/settings/account/apiNewToken'; describe('ApiNewToken', () => { @@ -140,4 +149,53 @@ describe('ApiNewToken', () => { ) ); }); + + it('shows new token modal after successful creation', async () => { + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + method: 'POST', + url: '/api-tokens/', + body: ApiTokenFixture({token: 'sntrys_test_token_123'}), + }); + + render(); + renderGlobalModal(); + + await selectEvent.select(screen.getByRole('textbox', {name: 'Project'}), 'Read'); + + await userEvent.click(screen.getByRole('button', {name: 'Create Token'})); + + expect(await screen.findByLabelText('Generated token')).toHaveValue( + 'sntrys_test_token_123' + ); + }); + + it('displays permissions preview when scopes are selected', async () => { + render(); + + await selectEvent.select(screen.getByRole('textbox', {name: 'Project'}), 'Read'); + expect(screen.getByText(/project:read/)).toBeInTheDocument(); + + await selectEvent.select(screen.getByRole('textbox', {name: 'Team'}), 'Admin'); + expect(screen.getByText(/team:admin/)).toBeInTheDocument(); + }); + + it('shows error message when token creation fails', async () => { + jest.spyOn(indicators, 'addErrorMessage'); + + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + method: 'POST', + url: '/api-tokens/', + statusCode: 400, + }); + + render(); + + await selectEvent.select(screen.getByRole('textbox', {name: 'Project'}), 'Read'); + + await userEvent.click(screen.getByRole('button', {name: 'Create Token'})); + + await waitFor(() => expect(indicators.addErrorMessage).toHaveBeenCalled()); + }); }); diff --git a/static/app/views/settings/account/apiNewToken.tsx b/static/app/views/settings/account/apiNewToken.tsx index 29aa3db22ef3bc..8352e47ee54609 100644 --- a/static/app/views/settings/account/apiNewToken.tsx +++ b/static/app/views/settings/account/apiNewToken.tsx @@ -1,10 +1,17 @@ import {useCallback, useState} from 'react'; +import {z} from 'zod'; +import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; +import {Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; -import {ApiForm} from 'sentry/components/forms/apiForm'; -import {TextareaField} from 'sentry/components/forms/fields/textareaField'; -import {TextField} from 'sentry/components/forms/fields/textField'; +import { + addErrorMessage, + addLoadingMessage, + addSuccessMessage, +} from 'sentry/actionCreators/indicator'; +import {FieldGroup} from 'sentry/components/forms/fieldGroup'; import {Panel} from 'sentry/components/panels/panel'; import {PanelBody} from 'sentry/components/panels/panelBody'; import {PanelHeader} from 'sentry/components/panels/panelHeader'; @@ -16,54 +23,101 @@ import { import {t, tct} from 'sentry/locale'; import type {Permissions} from 'sentry/types/integrations'; import type {NewInternalAppApiToken} from 'sentry/types/user'; +import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse'; +import {fetchMutation, useMutation, useQueryClient} from 'sentry/utils/queryClient'; +import type {RequestError} from 'sentry/utils/requestError/requestError'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; import {displayNewToken} from 'sentry/views/settings/components/newTokenHandler'; import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; import {TextBlock} from 'sentry/views/settings/components/text/textBlock'; -import {PermissionSelection} from 'sentry/views/settings/organizationDeveloperSettings/permissionSelection'; +import { + PermissionSelection, + permissionStateToList, +} from 'sentry/views/settings/organizationDeveloperSettings/permissionSelection'; const API_INDEX_ROUTE = '/settings/account/api/auth-tokens/'; -export default function ApiNewToken() { - const [permissions, setPermissions] = useState({ - Event: 'no-access', - Team: 'no-access', - Member: 'no-access', - Project: 'no-access', - Release: 'no-access', - Organization: 'no-access', - Alerts: 'no-access', - Distribution: 'no-access', - }); - const navigate = useNavigate(); - const [hasNewToken, setHasnewToken] = useState(false); - const [preview, setPreview] = useState(''); +const schema = z.object({ + name: z.string(), +}); - // Personal tokens can't be used for Distribution. The point of - // Distribution is to emebed the token into an app. We don't want people - // using personal tokens for that. - const displayedPermissions = SENTRY_APP_PERMISSIONS.filter( - o => o !== DISTRIBUTION_SENTRY_APP_PERMISSION - ); +const INITIAL_PERMISSIONS: Permissions = { + Event: 'no-access', + Team: 'no-access', + Member: 'no-access', + Project: 'no-access', + Release: 'no-access', + Organization: 'no-access', + Alerts: 'no-access', + Distribution: 'no-access', +}; - const getPreview = () => { - let previewString = ''; - for (const k in permissions) { - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - if (permissions[k] !== 'no-access') { - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - previewString += `${k.toLowerCase()}:${permissions[k]}\n`; - } - } - return previewString; - }; +// Personal tokens can't be used for Distribution. The point of +// Distribution is to embed the token into an app. We don't want people +// using personal tokens for that. +const DISPLAYED_PERMISSIONS = SENTRY_APP_PERMISSIONS.filter( + o => o !== DISTRIBUTION_SENTRY_APP_PERMISSION +); + +function getPermissionsPreview(permissions: Permissions): string { + return Object.entries(permissions) + .filter(([, access]) => access !== 'no-access') + .map(([resource, access]) => `${resource.toLowerCase()}:${access}`) + .join(', '); +} + +export default function ApiNewToken() { + const [permissions, setPermissions] = useState({...INITIAL_PERMISSIONS}); + const navigate = useNavigate(); + const queryClient = useQueryClient(); const handleGoBack = useCallback( () => navigate(normalizeUrl(API_INDEX_ROUTE)), [navigate] ); + const allPermissionsNoAccess = Object.values(permissions).every( + value => value === 'no-access' + ); + + const mutation = useMutation({ + mutationFn: (data: z.infer) => + fetchMutation({ + url: '/api-tokens/', + method: 'POST', + data: { + ...data, + scopes: permissionStateToList(permissions).filter( + (v): v is NonNullable => v !== undefined + ), + }, + }), + onSuccess: token => { + addSuccessMessage(t('Created personal token.')); + queryClient.invalidateQueries({queryKey: [getApiUrl('/api-tokens/')]}); + displayNewToken(token.token, handleGoBack); + }, + onError: (error: RequestError) => { + const message = t('Failed to create a new personal token.'); + handleXhrErrorResponse(message, error); + addErrorMessage(message); + }, + }); + + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: {name: ''}, + validators: {onDynamic: schema}, + onSubmit: ({value}) => { + addLoadingMessage(); + return mutation.mutateAsync(value).catch(() => {}); + }, + }); + + const permissionsPreview = getPermissionsPreview(permissions); + return (
@@ -81,59 +135,43 @@ export default function ApiNewToken() { } )} - { - setHasnewToken(true); - displayNewToken(token.token, handleGoBack); - }} - onCancel={handleGoBack} - footerStyle={{ - marginTop: 0, - paddingRight: 20, - }} - submitDisabled={ - !!hasNewToken || - Object.values(permissions).every(value => value === 'no-access') - } - submitLabel={t('Create Token')} - > - - {t('General')} - - - - + + + + {field => ( + + + + )} + + {t('Permissions')} { - setPermissions(p); - setPreview(getPreview()); - }} - displayedPermissions={displayedPermissions} + onChange={p => setPermissions({...p})} + displayedPermissions={DISPLAYED_PERMISSIONS} /> - + > +
{permissionsPreview || '—'}
+
-
+ + + + {t('Create Token')} + + +
); diff --git a/static/app/views/settings/account/apiTokenDetails.spec.tsx b/static/app/views/settings/account/apiTokenDetails.spec.tsx index 731e027416de1a..9aa6231a4b7c83 100644 --- a/static/app/views/settings/account/apiTokenDetails.spec.tsx +++ b/static/app/views/settings/account/apiTokenDetails.spec.tsx @@ -5,8 +5,49 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar import * as indicators from 'sentry/actionCreators/indicator'; import ApiTokenDetails from 'sentry/views/settings/account/apiTokenDetails'; -describe('ApiNewToken', () => { - MockApiClient.clearMockResponses(); +const ROUTER_CONFIG = { + initialRouterConfig: { + route: `/api/auth-tokens/:tokenId/`, + location: { + pathname: `/api/auth-tokens/1/`, + }, + }, +}; + +describe('ApiTokenDetails', () => { + it('renders token name, preview, and scopes', async () => { + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + method: 'GET', + url: `/api-tokens/1/`, + body: ApiTokenFixture({ + id: '1', + name: 'My Token', + scopes: ['project:read', 'project:write'], + tokenLastCharacters: 'n123', + }), + }); + + render(, ROUTER_CONFIG); + + const nameInput = await screen.findByRole('textbox', {name: /name/i}); + expect(nameInput).toHaveValue('My Token'); + expect(screen.getByText('************n123')).toBeInTheDocument(); + expect(screen.getByText('project:read, project:write')).toBeInTheDocument(); + }); + + it('shows error state when token fails to load', async () => { + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + method: 'GET', + url: `/api-tokens/1/`, + statusCode: 500, + }); + + render(, ROUTER_CONFIG); + + expect(await screen.findByText('Failed to load personal token.')).toBeInTheDocument(); + }); it('renames token to new name', async () => { MockApiClient.clearMockResponses(); @@ -18,14 +59,7 @@ describe('ApiNewToken', () => { body: ApiTokenFixture({id: '1', name: 'token1'}), }); - render(, { - initialRouterConfig: { - route: `/api/auth-tokens/:tokenId/`, - location: { - pathname: `/api/auth-tokens/1/`, - }, - }, - }); + render(, ROUTER_CONFIG); await waitFor(() => expect(mock1).toHaveBeenCalledTimes(1)); @@ -63,14 +97,7 @@ describe('ApiNewToken', () => { body: ApiTokenFixture({id: '1', name: 'token1'}), }); - render(, { - initialRouterConfig: { - route: `/api/auth-tokens/:tokenId/`, - location: { - pathname: `/api/auth-tokens/1/`, - }, - }, - }); + render(, ROUTER_CONFIG); await waitFor(() => expect(mock1).toHaveBeenCalledTimes(1)); @@ -108,14 +135,7 @@ describe('ApiNewToken', () => { body: ApiTokenFixture({id: '1', name: 'token1'}), }); - render(, { - initialRouterConfig: { - route: `/api/auth-tokens/:tokenId/`, - location: { - pathname: `/api/auth-tokens/1/`, - }, - }, - }); + render(, ROUTER_CONFIG); await waitFor(() => expect(mock1).toHaveBeenCalledTimes(1)); diff --git a/static/app/views/settings/account/apiTokenDetails.tsx b/static/app/views/settings/account/apiTokenDetails.tsx index 2cb49e491b158e..0ffa6113402f39 100644 --- a/static/app/views/settings/account/apiTokenDetails.tsx +++ b/static/app/views/settings/account/apiTokenDetails.tsx @@ -1,3 +1,9 @@ +import {useCallback} from 'react'; +import {z} from 'zod'; + +import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; +import {Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import { @@ -6,21 +12,23 @@ import { addSuccessMessage, } from 'sentry/actionCreators/indicator'; import {FieldGroup} from 'sentry/components/forms/fieldGroup'; -import {TextField} from 'sentry/components/forms/fields/textField'; -import {Form} from 'sentry/components/forms/form'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {Panel} from 'sentry/components/panels/panel'; -import {PanelBody} from 'sentry/components/panels/panelBody'; -import {PanelHeader} from 'sentry/components/panels/panelHeader'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t, tct} from 'sentry/locale'; import type {InternalAppApiToken} from 'sentry/types/user'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse'; -import {useApiQuery} from 'sentry/utils/queryClient'; +import { + fetchMutation, + getApiQueryData, + setApiQueryData, + useApiQuery, + useMutation, + useQueryClient, +} from 'sentry/utils/queryClient'; +import type {RequestError} from 'sentry/utils/requestError/requestError'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; -import {useMutateApiToken} from 'sentry/utils/useMutateApiToken'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useParams} from 'sentry/utils/useParams'; import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; @@ -37,68 +45,112 @@ type FetchApiTokenResponse = InternalAppApiToken; const makeFetchApiTokenKey = ({tokenId}: FetchApiTokenParameters) => [getApiUrl(`/api-tokens/$tokenId/`, {path: {tokenId}})] as const; +const API_TOKEN_LIST_KEY = [getApiUrl('/api-tokens/')] as const; + +const schema = z.object({ + name: z.string(), +}); + function ApiTokenDetailsForm({token}: {token: InternalAppApiToken}) { const navigate = useNavigate(); - const initialData = { - name: token.name, - tokenPreview: tokenPreview(token.tokenLastCharacters || '****'), - }; - - const handleGoBack = () => { - navigate(normalizeUrl(API_INDEX_ROUTE)); - }; - - const onSuccess = () => { - addSuccessMessage(t('Updated user auth token.')); - handleGoBack(); - }; - - const onError = (error: any) => { - const message = t('Failed to update the user auth token.'); - handleXhrErrorResponse(message, error); - addErrorMessage(message); - }; - - const {mutate: submitToken} = useMutateApiToken({ - token, - onSuccess, - onError, + const queryClient = useQueryClient(); + + const handleGoBack = useCallback( + () => navigate(normalizeUrl(API_INDEX_ROUTE)), + [navigate] + ); + + const mutation = useMutation({ + mutationFn: (data: z.infer) => + fetchMutation({ + url: `/api-tokens/${token.id}/`, + method: 'PUT', + data, + }), + onSuccess: (_data, {name}) => { + addSuccessMessage(t('Updated user auth token.')); + + // Update get by id query + setApiQueryData( + queryClient, + makeFetchApiTokenKey({tokenId: token.id}), + (oldData: InternalAppApiToken | undefined) => { + if (!oldData) { + return oldData; + } + return {...oldData, name}; + } + ); + + // Update get list query + if (getApiQueryData(queryClient, API_TOKEN_LIST_KEY)) { + setApiQueryData( + queryClient, + API_TOKEN_LIST_KEY, + (oldData: InternalAppApiToken[] | undefined) => { + if (!Array.isArray(oldData)) { + return oldData; + } + return oldData.map(oldToken => + oldToken.id === token.id ? {...oldToken, name} : oldToken + ); + } + ); + } + + handleGoBack(); + }, + onError: (error: RequestError) => { + const message = t('Failed to update the user auth token.'); + handleXhrErrorResponse(message, error); + addErrorMessage(message); + }, + }); + + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: {name: token.name}, + validators: {onDynamic: schema}, + onSubmit: ({value}) => { + addLoadingMessage(); + return mutation.mutateAsync(value).catch(() => {}); + }, }); return ( -
{ - addLoadingMessage(); - - return submitToken({ - name, - }); - }} - onCancel={handleGoBack} - > - - - - - -
{token.scopes.slice().sort().join(', ')}
-
- + + + + {field => ( + + + + )} + + + +
{tokenPreview(token.tokenLastCharacters || '****')}
+
+ + +
{token.scopes.slice().sort().join(', ')}
+
+
+ + + + {t('Save Changes')} + +
); } @@ -132,22 +184,17 @@ function ApiTokenDetails() { } )} - - {t('Personal Token Details')} - - - {isError && ( - - )} - {isPending && } + {isError && ( + + )} + + {isPending && } - {!isPending && !isError && token && } - - + {!isPending && !isError && token && }
); } diff --git a/static/app/views/settings/organizationDeveloperSettings/permissionSelection.tsx b/static/app/views/settings/organizationDeveloperSettings/permissionSelection.tsx index 43e998c3d44a72..1966b2951756d0 100644 --- a/static/app/views/settings/organizationDeveloperSettings/permissionSelection.tsx +++ b/static/app/views/settings/organizationDeveloperSettings/permissionSelection.tsx @@ -107,7 +107,7 @@ function findResource(r: PermissionResource) { * ['org:read', 'org:write', ...] * */ -function permissionStateToList(permissions: Permissions) { +export function permissionStateToList(permissions: Permissions) { return Object.entries(permissions).flatMap( ([r, p]) => findResource(r as PermissionResource)?.choices?.[p]?.scopes ); @@ -130,7 +130,10 @@ export class PermissionSelection extends Component { save = (permissions: Permissions) => { this.setState({permissions}); this.props.onChange(permissions); - this.context.form.setValue( + // When used inside a legacy FormModel-based form, sync the scopes field. + // When used outside that context (e.g. with useScrapsForm), the parent + // derives scopes from the onChange callback instead. + this.context.form?.setValue( 'scopes', permissionStateToList(this.state.permissions) as string[] ); From 7a2c6f48626c6e7530148107f20e84ea3b32bab0 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 7 Apr 2026 12:55:12 +0200 Subject: [PATCH 02/18] fix(ui): Remove overflow hidden from GuidedSteps StepDetails (#112336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove `overflow: hidden` from `StepDetails` in the `GuidedSteps` component. ### Background `StepDetails` is a grid item in a `34px 1fr` layout. When content inside it (like a code block with long lines) had a large intrinsic width, it would push past its `1fr` column and break the layout on small viewports. To prevent this, `overflow: hidden` was added — it worked, but it also clipped everything that extended beyond the container bounds, including focus rings on form elements (which use `box-shadow` in Sentry's design system): image ### What happened On **Mar 25**, Evans noticed the clipping issue and removed `overflow: hidden` in #111462. But without it, content started overflowing on small screens again, so on **Mar 27** it was reverted in 939e726b947. Then on **Mar 31**, Lazar landed #111657 which added `min-width: 0` to `StepDetails` to fix a code block overflow issue in the Metrics onboarding. Since the revert had already restored `overflow: hidden`, the overflow was no longer visible — but `min-width: 0` was still the right addition, and it also happens to solve the clipping issue, since it lets the grid item respect its track width without needing to clip its contents. ### This PR Now that `min-width: 0` handles the overflow problem, `overflow: hidden` is redundant and can be safely removed — finishing what Evans started in #111462. closes https://linear.app/getsentry/issue/DE-1048/stepper-overflow-hidden-clips-focus-rings Co-authored-by: Claude Opus 4.6 --- static/app/components/guidedSteps/guidedSteps.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/static/app/components/guidedSteps/guidedSteps.tsx b/static/app/components/guidedSteps/guidedSteps.tsx index 419b34849b5e5f..03a3658b429233 100644 --- a/static/app/components/guidedSteps/guidedSteps.tsx +++ b/static/app/components/guidedSteps/guidedSteps.tsx @@ -371,7 +371,6 @@ const ChildrenWrapper = styled('div')<{isActive: boolean}>` `; const StepDetails = styled('div')` - overflow: hidden; grid-area: details; min-width: 0; `; From 2b9fb0895c9b3e2e124425115878c2effaf5e7dc Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Tue, 7 Apr 2026 13:06:31 +0200 Subject: [PATCH 03/18] chore(dynamic-sampling): remove CustomDynamicSamplingRule models & mark tables for deletion (#112093) --- migrations_lockfile.txt | 2 +- .../1063_remove_customdynamicsamplingrule.py | 54 ++++++++++++++ src/sentry/models/__init__.py | 1 - src/sentry/models/dynamicsampling.py | 74 ------------------- .../services/organization/impl.py | 2 - src/sentry/testutils/helpers/backups.py | 20 +---- tests/sentry/users/models/test_user.py | 3 - 7 files changed, 56 insertions(+), 100 deletions(-) create mode 100644 src/sentry/migrations/1063_remove_customdynamicsamplingrule.py delete mode 100644 src/sentry/models/dynamicsampling.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 5dc8112c18489d..cfa5664e557e73 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access seer: 0005_delete_seerorganizationsettings -sentry: 1062_backfill_eventattachment_date_expires +sentry: 1063_remove_customdynamicsamplingrule social_auth: 0003_social_auth_json_field diff --git a/src/sentry/migrations/1063_remove_customdynamicsamplingrule.py b/src/sentry/migrations/1063_remove_customdynamicsamplingrule.py new file mode 100644 index 00000000000000..23c3d41982f6df --- /dev/null +++ b/src/sentry/migrations/1063_remove_customdynamicsamplingrule.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.11 on 2026-04-02 +import django.db.models.deletion +from django.db import migrations + +import sentry.db.models.fields.foreignkey +from sentry.new_migrations.migrations import CheckedMigration +from sentry.new_migrations.monkey.models import SafeDeleteModel +from sentry.new_migrations.monkey.state import DeletionAction + + +class Migration(CheckedMigration): + is_post_deployment = False + + dependencies = [ + ("sentry", "1062_backfill_eventattachment_date_expires"), + ] + + operations = [ + migrations.AlterField( + model_name="customdynamicsamplingruleproject", + name="custom_dynamic_sampling_rule", + field=sentry.db.models.fields.foreignkey.FlexibleForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + to="sentry.customdynamicsamplingrule", + ), + ), + migrations.AlterField( + model_name="customdynamicsamplingruleproject", + name="project", + field=sentry.db.models.fields.foreignkey.FlexibleForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + to="sentry.project", + ), + ), + migrations.AlterField( + model_name="customdynamicsamplingrule", + name="organization", + field=sentry.db.models.fields.foreignkey.FlexibleForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + to="sentry.organization", + ), + ), + SafeDeleteModel( + name="CustomDynamicSamplingRuleProject", + deletion_action=DeletionAction.MOVE_TO_PENDING, + ), + SafeDeleteModel( + name="CustomDynamicSamplingRule", + deletion_action=DeletionAction.MOVE_TO_PENDING, + ), + ] diff --git a/src/sentry/models/__init__.py b/src/sentry/models/__init__.py index f239ca916dcda7..5cb51e40e797d3 100644 --- a/src/sentry/models/__init__.py +++ b/src/sentry/models/__init__.py @@ -31,7 +31,6 @@ from .deletedteam import * # NOQA from .deploy import * # NOQA from .distribution import * # NOQA -from .dynamicsampling import * # NOQA from .environment import * # NOQA from .event import * # NOQA from .eventattachment import * # NOQA diff --git a/src/sentry/models/dynamicsampling.py b/src/sentry/models/dynamicsampling.py deleted file mode 100644 index 9ed171a1bc2524..00000000000000 --- a/src/sentry/models/dynamicsampling.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -from django.db import models -from django.db.models import Q -from django.utils import timezone - -from sentry.backup.scopes import RelocationScope -from sentry.db.models import FlexibleForeignKey, Model, cell_silo_model -from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey - - -@cell_silo_model -class CustomDynamicSamplingRuleProject(Model): - """ - Many-to-many relationship between a custom dynamic sampling rule and a project. - """ - - __relocation_scope__ = RelocationScope.Organization - - custom_dynamic_sampling_rule = FlexibleForeignKey( - "sentry.CustomDynamicSamplingRule", on_delete=models.CASCADE - ) - project = FlexibleForeignKey("sentry.Project", on_delete=models.CASCADE) - - class Meta: - app_label = "sentry" - db_table = "sentry_customdynamicsamplingruleproject" - unique_together = (("custom_dynamic_sampling_rule", "project"),) - - -@cell_silo_model -class CustomDynamicSamplingRule(Model): - """ - This represents a custom dynamic sampling rule that is created by the user based - on a query (a.k.a. investigation rule). - - """ - - __relocation_scope__ = RelocationScope.Organization - - date_added = models.DateTimeField(default=timezone.now) - organization = FlexibleForeignKey("sentry.Organization", on_delete=models.CASCADE) - projects = models.ManyToManyField( - "sentry.Project", - related_name="custom_dynamic_sampling_rules", - through=CustomDynamicSamplingRuleProject, - ) - is_active = models.BooleanField(default=True) - is_org_level = models.BooleanField(default=False) - rule_id = models.IntegerField(default=0) - condition = models.TextField() - sample_rate = models.FloatField(default=0.0) - start_date = models.DateTimeField(default=timezone.now) - end_date = models.DateTimeField() - num_samples = models.IntegerField() - condition_hash = models.CharField(max_length=40) - # the raw query field from the request - query = models.TextField(null=True) - created_by_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE", null=True, blank=True) - notification_sent = models.BooleanField(null=True, blank=True) - - class Meta: - app_label = "sentry" - db_table = "sentry_customdynamicsamplingrule" - indexes = [ - # get active rules for an organization - models.Index(fields=["organization"], name="org_idx", condition=Q(is_active=True)), - # get expired rules (that are still marked as active) - models.Index(fields=["end_date"], name="end_date_idx", condition=Q(is_active=True)), - # find active rules for a condition - models.Index( - fields=["condition_hash"], name="condition_hash_idx", condition=Q(is_active=True) - ), - ] diff --git a/src/sentry/organizations/services/organization/impl.py b/src/sentry/organizations/services/organization/impl.py index 2cdb2a01a3edc6..5b7c8db37cf3fd 100644 --- a/src/sentry/organizations/services/organization/impl.py +++ b/src/sentry/organizations/services/organization/impl.py @@ -20,7 +20,6 @@ from sentry.incidents.models.incident import IncidentActivity from sentry.models.activity import Activity from sentry.models.dashboard import Dashboard, DashboardFavoriteUser -from sentry.models.dynamicsampling import CustomDynamicSamplingRule from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView @@ -581,7 +580,6 @@ def merge_users(self, *, organization_id: int, from_user_id: int, to_user_id: in Activity, AlertRule, AlertRuleActivity, - CustomDynamicSamplingRule, Dashboard, DashboardFavoriteUser, GroupAssignee, diff --git a/src/sentry/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py index 3b980ccdc62eef..f000b211a16f6b 100644 --- a/src/sentry/testutils/helpers/backups.py +++ b/src/sentry/testutils/helpers/backups.py @@ -3,7 +3,7 @@ import io import tempfile from copy import deepcopy -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from functools import cached_property, cmp_to_key from pathlib import Path from typing import Any @@ -77,10 +77,6 @@ DashboardWidgetQueryOnDemand, DashboardWidgetTypes, ) -from sentry.models.dynamicsampling import ( - CustomDynamicSamplingRule, - CustomDynamicSamplingRuleProject, -) from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView, GroupSearchViewProject @@ -819,20 +815,6 @@ def create_exhaustive_organization( overrides={"write_key": "test_override_write_key"}, ) - custom_rule = CustomDynamicSamplingRule.objects.create( - organization=org, - created_by_id=owner_id, - condition='{"op":"and","inner":[]}', - end_date=timezone.now() + timedelta(days=1), - num_samples=100, - condition_hash="abc123def456abc123def456abc123def4560000", - sample_rate=0.5, - ) - CustomDynamicSamplingRuleProject.objects.create( - custom_dynamic_sampling_rule=custom_rule, - project=project, - ) - return org @assume_test_silo_mode(SiloMode.CONTROL) diff --git a/tests/sentry/users/models/test_user.py b/tests/sentry/users/models/test_user.py index 238dafb0925dfe..945c8a7048e781 100644 --- a/tests/sentry/users/models/test_user.py +++ b/tests/sentry/users/models/test_user.py @@ -17,7 +17,6 @@ from sentry.models.activity import Activity from sentry.models.authidentity import AuthIdentity from sentry.models.dashboard import Dashboard, DashboardFavoriteUser -from sentry.models.dynamicsampling import CustomDynamicSamplingRule from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView @@ -497,7 +496,6 @@ def test_duplicate_memberships(self, expected_models: list[type[Model]]) -> None Activity, AlertRule, AlertRuleActivity, - CustomDynamicSamplingRule, Dashboard, DashboardFavoriteUser, GroupAssignee, @@ -541,7 +539,6 @@ def test_only_source_user_is_member_of_organization( Activity, AlertRule, AlertRuleActivity, - CustomDynamicSamplingRule, Dashboard, DashboardFavoriteUser, GroupAssignee, From 5f7a276d4e6cc0251d1a63b78d9c0745d315a2e8 Mon Sep 17 00:00:00 2001 From: Hector Dearman Date: Tue, 7 Apr 2026 12:37:00 +0100 Subject: [PATCH 04/18] ref(autofix): Unnest if (#112114) --- src/sentry/seer/autofix/issue_summary.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index 367131d81def82..08361e53317b55 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -428,16 +428,15 @@ def run_automation( return # Check event count for ALERT source with seat-based tier - if is_seer_seat_based_tier_enabled(group.organization): - if source == SeerAutomationSource.ALERT: - # Use times_seen_with_pending if available (set by post_process), otherwise fall back - times_seen = ( - group.times_seen_with_pending - if hasattr(group, "_times_seen_pending") - else group.times_seen - ) - if times_seen < AUTOFIX_AUTOMATION_OCCURRENCE_THRESHOLD: - return + if is_seer_seat_based_tier_enabled(group.organization) and source == SeerAutomationSource.ALERT: + # Use times_seen_with_pending if available (set by post_process), otherwise fall back + times_seen = ( + group.times_seen_with_pending + if hasattr(group, "_times_seen_pending") + else group.times_seen + ) + if times_seen < AUTOFIX_AUTOMATION_OCCURRENCE_THRESHOLD: + return user_id = user.id if user else None auto_run_source = auto_run_source_map.get(source, "unknown_source") From 36c5869e8106d36c71c32ad26594eb35b66438cd Mon Sep 17 00:00:00 2001 From: Hector Dearman Date: Tue, 7 Apr 2026 12:39:00 +0100 Subject: [PATCH 05/18] ref(preprod): Remove PreprodStaticGroupType and PreprodDeltaGroupType (#112337) Remove the unused `PreprodStaticGroupType` (type_id 11001) and `PreprodDeltaGroupType` (type_id 11002) group types. Neither class was referenced anywhere in the codebase. The `grouptype.py` module is retained for its side-effect import of `sentry.preprod.size_analysis.grouptype`. Agent transcript: https://claudescope.sentry.dev/share/TB-Jn6n67lNj6NQ-Hu4JOrlbup4fW28mdJdywsxjyoY --- src/sentry/preprod/grouptype.py | 47 --------------------------------- 1 file changed, 47 deletions(-) diff --git a/src/sentry/preprod/grouptype.py b/src/sentry/preprod/grouptype.py index 8a34ada04615b2..e51ecd89089983 100644 --- a/src/sentry/preprod/grouptype.py +++ b/src/sentry/preprod/grouptype.py @@ -1,54 +1,7 @@ from __future__ import annotations -from dataclasses import dataclass - import sentry.preprod.size_analysis.grouptype # noqa: F401,F403 -from sentry.issues.grouptype import GroupCategory, GroupType -from sentry.types.group import PriorityLevel # We have to import sentry.preprod.size_analysis.grouptype above. # grouptype modules in root packages (src/sentry/*) are auto imported # but more deeply nested ones are not. - - -@dataclass(frozen=True) -class PreprodStaticGroupType(GroupType): - """ - Issues detected in a single uploaded artifact. For example an - Android app not being 16kb page size ready. - Typically these end up grouped across multiple builds e.g. if CI - uploads a build of an app for each commit to main each of those - uploads could result in an occurrence of some issue like the 16kb - page size. - """ - - type_id = 11001 - slug = "preprod_static" - description = "Static Analysis" - category = GroupCategory.PREPROD.value - category_v2 = GroupCategory.PREPROD.value - default_priority = PriorityLevel.LOW - released = False - enable_auto_resolve = True - enable_escalation_detection = False - - -@dataclass(frozen=True) -class PreprodDeltaGroupType(GroupType): - """ - Issues detected examining the delta between two uploaded artifacts. - For example a binary size regression. These are typically *not* - grouped. A size regression between v1 and v2 likely does not have - the same root cause (and hence resolution) as another regression - between v2 and v3. - """ - - type_id = 11002 - slug = "preprod_delta" - description = "Static Analysis Delta" - category = GroupCategory.PREPROD.value - category_v2 = GroupCategory.PREPROD.value - default_priority = PriorityLevel.LOW - released = False - enable_auto_resolve = True - enable_escalation_detection = False From 52883b66c3a8bbd9136b5340f7147f9935e8b681 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 7 Apr 2026 13:59:15 +0200 Subject: [PATCH 06/18] fix(onboarding): change agent monitoring empty state to use openai instead of openai-agents (#112344) Closes https://linear.app/getsentry/issue/TET-2173/select-openai-by-default --- .../python/agentMonitoring.tsx | 39 ++++++++++--------- .../pages/agents/utils/agentIntegrations.tsx | 2 +- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/static/app/gettingStartedDocs/python/agentMonitoring.tsx b/static/app/gettingStartedDocs/python/agentMonitoring.tsx index 3cf2642d59b7f2..591386bfaba0b4 100644 --- a/static/app/gettingStartedDocs/python/agentMonitoring.tsx +++ b/static/app/gettingStartedDocs/python/agentMonitoring.tsx @@ -8,6 +8,7 @@ import {StepType} from 'sentry/components/onboarding/gettingStartedDoc/types'; import {t, tct} from 'sentry/locale'; import {SdkUpdateAlert} from 'sentry/views/insights/pages/agents/components/sdkUpdateAlert'; import {ManualInstrumentationNote} from 'sentry/views/insights/pages/agents/llmOnboardingInstructions'; +import {AgentIntegration} from 'sentry/views/insights/pages/agents/utils/agentIntegrations'; import {getPythonInstallCodeBlock} from './utils'; @@ -293,29 +294,30 @@ sentry_sdk.init( ], }; - const selected = (params.platformOptions as any)?.integration ?? 'openai_agents'; - if (selected === 'openai') { + const selected = + (params.platformOptions as any)?.integration ?? AgentIntegration.OPENAI; + if (selected === AgentIntegration.OPENAI) { return [openaiSdkStep]; } - if (selected === 'anthropic') { + if (selected === AgentIntegration.ANTHROPIC) { return [anthropicSdkStep]; } - if (selected === 'langchain') { + if (selected === AgentIntegration.LANGCHAIN) { return [langchainStep]; } - if (selected === 'langgraph') { + if (selected === AgentIntegration.LANGGRAPH) { return [langgraphStep]; } - if (selected === 'litellm') { + if (selected === AgentIntegration.LITTELLM) { return [liteLLMStep]; } - if (selected === 'google_genai') { + if (selected === AgentIntegration.GOOGLE_GENAI) { return [googleGenAIStep]; } - if (selected === 'pydantic_ai') { + if (selected === AgentIntegration.PYDANTIC_AI) { return [pydanticAiStep]; } - if (selected === 'manual') { + if (selected === AgentIntegration.MANUAL) { return [manualStep]; } return [openaiAgentsStep]; @@ -565,29 +567,30 @@ print(result.output) ], }; - const selected = (params.platformOptions as any)?.integration ?? 'openai_agents'; - if (selected === 'openai') { + const selected = + (params.platformOptions as any)?.integration ?? AgentIntegration.OPENAI; + if (selected === AgentIntegration.OPENAI) { return [openaiSdkVerifyStep]; } - if (selected === 'anthropic') { + if (selected === AgentIntegration.ANTHROPIC) { return [anthropicSdkVerifyStep]; } - if (selected === 'langchain') { + if (selected === AgentIntegration.LANGCHAIN) { return [langchainVerifyStep]; } - if (selected === 'langgraph') { + if (selected === AgentIntegration.LANGGRAPH) { return [langgraphVerifyStep]; } - if (selected === 'litellm') { + if (selected === AgentIntegration.LITTELLM) { return [liteLLMVerifyStep]; } - if (selected === 'google_genai') { + if (selected === AgentIntegration.GOOGLE_GENAI) { return [googleGenAIVerifyStep]; } - if (selected === 'pydantic_ai') { + if (selected === AgentIntegration.PYDANTIC_AI) { return [pydanticAiVerifyStep]; } - if (selected === 'manual') { + if (selected === AgentIntegration.MANUAL) { return [manualVerifyStep]; } return [openaiAgentsVerifyStep]; diff --git a/static/app/views/insights/pages/agents/utils/agentIntegrations.tsx b/static/app/views/insights/pages/agents/utils/agentIntegrations.tsx index db4835f8f23245..c9dbb850046690 100644 --- a/static/app/views/insights/pages/agents/utils/agentIntegrations.tsx +++ b/static/app/views/insights/pages/agents/utils/agentIntegrations.tsx @@ -41,13 +41,13 @@ export const AGENT_INTEGRATION_ICONS: Record = { }; export const PYTHON_AGENT_INTEGRATIONS = [ + AgentIntegration.OPENAI, AgentIntegration.OPENAI_AGENTS, AgentIntegration.ANTHROPIC, AgentIntegration.GOOGLE_GENAI, AgentIntegration.LANGCHAIN, AgentIntegration.LANGGRAPH, AgentIntegration.LITTELLM, - AgentIntegration.OPENAI, AgentIntegration.PYDANTIC_AI, AgentIntegration.MANUAL, ]; From fc84cb27bec5e42e0898df0cb70690b0b90c4e9f Mon Sep 17 00:00:00 2001 From: Hector Dearman Date: Tue, 7 Apr 2026 13:07:27 +0100 Subject: [PATCH 07/18] fix(codeowners): Add coverage for markdownTextArea and clean baseline (#112339) Add `markdownTextArea.tsx` to CODEOWNERS Agent transcript: https://claudescope.sentry.dev/share/871UYxUBaZ8vQJ9ftWv61JC9JCwVPM23lLo0kqC_Q3o --- .github/CODEOWNERS | 1 + .github/codeowners-coverage-baseline.txt | 25 ------------------------ 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5baea3ba65fdcb..9a7b27d8e79af5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -439,6 +439,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/components/loading/ @getsentry/app-frontend /static/app/components/events/interfaces/ @getsentry/app-frontend /static/app/components/forms/ @getsentry/app-frontend +/static/app/components/markdownTextArea.tsx @getsentry/app-frontend /static/app/locale.tsx @getsentry/app-frontend ## End of Frontend diff --git a/.github/codeowners-coverage-baseline.txt b/.github/codeowners-coverage-baseline.txt index b6247777dae29f..1f28e295e561c4 100644 --- a/.github/codeowners-coverage-baseline.txt +++ b/.github/codeowners-coverage-baseline.txt @@ -253,11 +253,6 @@ src/sentry/stacktraces/processing.py src/sentry/status_checks/__init__.py src/sentry/status_checks/base.py src/sentry/status_checks/warnings.py -src/sentry/synapse/__init__.py -src/sentry/synapse/endpoints/__init__.py -src/sentry/synapse/endpoints/authentication.py -src/sentry/synapse/endpoints/org_cell_mappings.py -src/sentry/synapse/paginator.py src/sentry/tagstore/__init__.py src/sentry/tagstore/base.py src/sentry/tagstore/exceptions.py @@ -643,18 +638,6 @@ static/app/components/events/eventTagsAndScreenshot/tags.tsx static/app/components/events/eventViewHierarchy.spec.tsx static/app/components/events/eventViewHierarchy.tsx static/app/components/events/eventXrayDiff.tsx -static/app/components/events/groupingInfo/groupingComponent.tsx -static/app/components/events/groupingInfo/groupingComponentChildren.tsx -static/app/components/events/groupingInfo/groupingComponentFrames.tsx -static/app/components/events/groupingInfo/groupingComponentStacktrace.tsx -static/app/components/events/groupingInfo/groupingInfo.tsx -static/app/components/events/groupingInfo/groupingInfoSection.spec.tsx -static/app/components/events/groupingInfo/groupingInfoSection.tsx -static/app/components/events/groupingInfo/groupingSummary.tsx -static/app/components/events/groupingInfo/groupingVariant.spec.tsx -static/app/components/events/groupingInfo/groupingVariant.tsx -static/app/components/events/groupingInfo/useEventGroupingInfo.tsx -static/app/components/events/groupingInfo/utils.tsx static/app/components/events/meta/annotatedText/annotatedTextErrors.tsx static/app/components/events/meta/annotatedText/annotatedTextValue.tsx static/app/components/events/meta/annotatedText/filteredAnnotatedTextValue.tsx @@ -789,8 +772,6 @@ static/app/components/list/index.tsx static/app/components/list/listItem.tsx static/app/components/list/utils.tsx static/app/components/listGroup.tsx -static/app/components/loading/loadingContainer.spec.tsx -static/app/components/loading/loadingContainer.tsx static/app/components/loadingError.stories.tsx static/app/components/loadingError.tsx static/app/components/loadingIndicator.stories.tsx @@ -2258,7 +2239,6 @@ tests/sentry/receivers/outbox/test_control.py tests/sentry/receivers/test_analytics.py tests/sentry/receivers/test_core.py tests/sentry/receivers/test_data_forwarding.py -tests/sentry/receivers/test_default_detector.py tests/sentry/receivers/test_featureadoption.py tests/sentry/receivers/test_onboarding.py tests/sentry/receivers/test_releases.py @@ -2357,10 +2337,6 @@ tests/sentry/sudo/test_middleware.py tests/sentry/sudo/test_signals.py tests/sentry/sudo/test_utils.py tests/sentry/sudo/test_views.py -tests/sentry/synapse/__init__.py -tests/sentry/synapse/endpoints/__init__.py -tests/sentry/synapse/endpoints/test_org_cell_mappings.py -tests/sentry/synapse/test_paginator.py tests/sentry/tagstore/__init__.py tests/sentry/tagstore/test_types.py tests/sentry/tasks/__init__.py @@ -2535,7 +2511,6 @@ tests/social_auth/test_utils.py tests/tools/__init__.py tests/tools/test_api_urls_to_typescript.py tests/tools/test_bump_action.py -tests/tools/test_compute_selected_tests.py tests/tools/test_flake8_plugin.py tests/tools/test_lint_requirements.py tests/tools/test_pin_github_action.py From b456371b171e85d179c99c5dbe1fa55031f77206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 7 Apr 2026 09:20:49 -0400 Subject: [PATCH 08/18] fix(test): open overlays immediately when in test mode (#112196) Removes the pattern of passing `delay={0}` / `displayTimeout={0}` in specs: for _opening_ hover overlays immediately. That's now done automatically in `useHoverOverlay` when `NODE_ENV === "test"`. This way we don't need to manually create & pipe zero-value delay props around various components. Note that _closing_ timing is unchanged. That way any triggers to portal content / async events still have a grace period in tests. A few seemingly-unrelated tests had to be updated to account for overlays now suddenly being available. I think this is good, actually, because they previously were relying on timed opening not having happened yet. See 92f377910c3a15022fd0a8c979334098c7e206dc. Fixes ENG-7208. Fixes ENG-7211. Fixes ENG-7212. Supersedes #111926, #111928, #111929, and #112004. --- .../components/core/tooltip/tooltip.spec.tsx | 20 +++---- static/app/components/hovercard.spec.tsx | 58 ++++++------------- .../stackTrace/issueStackTrace/index.spec.tsx | 4 +- static/app/utils/useHoverOverlay.tsx | 3 +- static/app/views/dashboards/detail.spec.tsx | 27 ++++++--- .../quickContextHovercard.spec.tsx | 2 +- .../projectInstall/createProject.spec.tsx | 4 +- 7 files changed, 54 insertions(+), 64 deletions(-) diff --git a/static/app/components/core/tooltip/tooltip.spec.tsx b/static/app/components/core/tooltip/tooltip.spec.tsx index b59448c9147672..be762f0d3b0480 100644 --- a/static/app/components/core/tooltip/tooltip.spec.tsx +++ b/static/app/components/core/tooltip/tooltip.spec.tsx @@ -27,7 +27,7 @@ describe('Tooltip', () => { it('renders', async () => { render( - + My Button ); @@ -46,14 +46,14 @@ describe('Tooltip', () => { it('updates title', async () => { const {rerender} = render( - + My Button ); // Change title rerender( - + My Button ); @@ -69,7 +69,7 @@ describe('Tooltip', () => { it('disables and does not render', async () => { render( - + My Button ); @@ -83,7 +83,7 @@ describe('Tooltip', () => { it('resets visibility when becoming disabled', async () => { const {rerender} = render( - + My Button ); @@ -92,7 +92,7 @@ describe('Tooltip', () => { expect(screen.getByText('test')).toBeInTheDocument(); rerender( - + My Button ); @@ -100,7 +100,7 @@ describe('Tooltip', () => { // Becomes enabled again rerender( - + My Button ); @@ -109,7 +109,7 @@ describe('Tooltip', () => { it('does not render an empty tooltip', async () => { render( - + My Button ); @@ -125,7 +125,7 @@ describe('Tooltip', () => { mockOverflow(100, 50); render( - +
This text overflows
); @@ -141,7 +141,7 @@ describe('Tooltip', () => { mockOverflow(50, 100); render( - +
This text does not overflow
); diff --git a/static/app/components/hovercard.spec.tsx b/static/app/components/hovercard.spec.tsx index b0e9f6ed10457d..86577c3fc9f6f6 100644 --- a/static/app/components/hovercard.spec.tsx +++ b/static/app/components/hovercard.spec.tsx @@ -7,50 +7,36 @@ describe('Hovercard', () => { jest.clearAllMocks(); }); - it('Displays card', async () => { + it('does not display card before hover', () => { render( - + Hovercard Trigger ); - await userEvent.hover(screen.getByText('Hovercard Trigger')); - - expect(await screen.findByText(/Hovercard Body/)).toBeInTheDocument(); - expect(await screen.findByText(/Hovercard Header/)).toBeInTheDocument(); + expect(screen.queryByText(/Hovercard Body/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Hovercard Header/)).not.toBeInTheDocument(); }); - it('Does not display card', async () => { + it('displays the card when hovered', async () => { render( - + Hovercard Trigger ); await userEvent.hover(screen.getByText('Hovercard Trigger')); - expect(screen.queryByText(/Hovercard Body/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Hovercard Header/)).not.toBeInTheDocument(); + expect(await screen.findByText(/Hovercard Body/)).toBeInTheDocument(); + expect(await screen.findByText(/Hovercard Header/)).toBeInTheDocument(); }); - it('Always displays card', async () => { + it('always displays card when forceVisible is true', async () => { render( Hovercard Trigger @@ -61,14 +47,13 @@ describe('Hovercard', () => { expect(screen.getByText(/Hovercard Header/)).toBeInTheDocument(); }); - it('Respects displayTimeout displays card', async () => { + it('respects displayTimeout to delay hiding card when hover is removed', async () => { const DISPLAY_TIMEOUT = 100; render( Hovercard Trigger @@ -77,26 +62,22 @@ describe('Hovercard', () => { jest.useFakeTimers(); await userEvent.hover(screen.getByText('Hovercard Trigger'), {delay: null}); - act(() => jest.advanceTimersByTime(DISPLAY_TIMEOUT - 1)); - - expect(screen.queryByText(/Hovercard Body/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Hovercard Header/)).not.toBeInTheDocument(); - - act(() => jest.advanceTimersByTime(1)); + await userEvent.unhover(screen.getByText('Hovercard Trigger'), {delay: null}); - expect(await screen.findByText(/Hovercard Body/)).toBeInTheDocument(); - expect(await screen.findByText(/Hovercard Header/)).toBeInTheDocument(); + act(() => jest.advanceTimersByTime(DISPLAY_TIMEOUT - 1)); jest.useRealTimers(); + + expect(screen.getByText(/Hovercard Body/)).toBeInTheDocument(); + expect(screen.getByText(/Hovercard Header/)).toBeInTheDocument(); }); - it('Doesnt leak timeout', async () => { + it('hides the cards after the display timeout when hover is removed', async () => { const DISPLAY_TIMEOUT = 100; render( Hovercard Trigger @@ -105,14 +86,9 @@ describe('Hovercard', () => { jest.useFakeTimers(); await userEvent.hover(screen.getByText('Hovercard Trigger'), {delay: null}); - act(() => jest.advanceTimersByTime(DISPLAY_TIMEOUT - 1)); - - expect(screen.queryByText(/Hovercard Body/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Hovercard Header/)).not.toBeInTheDocument(); - await userEvent.unhover(screen.getByText('Hovercard Trigger'), {delay: null}); - act(() => jest.advanceTimersByTime(1)); + act(() => jest.advanceTimersByTime(DISPLAY_TIMEOUT)); jest.useRealTimers(); expect(screen.queryByText(/Hovercard Body/)).not.toBeInTheDocument(); diff --git a/static/app/components/stackTrace/issueStackTrace/index.spec.tsx b/static/app/components/stackTrace/issueStackTrace/index.spec.tsx index 6ffbd392f1da12..dfbebe996e3c4a 100644 --- a/static/app/components/stackTrace/issueStackTrace/index.spec.tsx +++ b/static/app/components/stackTrace/issueStackTrace/index.spec.tsx @@ -246,7 +246,9 @@ describe('IssueStackTrace', () => { await userEvent.hover(screen.getByLabelText('Line 112')); - expect(await screen.findByText('Line uncovered by tests')).toBeInTheDocument(); + await waitFor(() => + expect(screen.getAllByText('Line uncovered by tests')).toHaveLength(2) + ); }); it('renders annotated text when exception value has PII scrubbing metadata', async () => { diff --git a/static/app/utils/useHoverOverlay.tsx b/static/app/utils/useHoverOverlay.tsx index 28f20876a6ea95..23e5a8c9fb248a 100644 --- a/static/app/utils/useHoverOverlay.tsx +++ b/static/app/utils/useHoverOverlay.tsx @@ -13,6 +13,7 @@ import {usePopper} from 'react-popper'; import {useTheme} from '@emotion/react'; import {mergeProps, mergeRefs} from '@react-aria/utils'; +import {NODE_ENV} from 'sentry/constants'; import type {Theme} from 'sentry/utils/theme'; function makeDefaultPopperModifiers(arrowElement: HTMLElement | null, offset: number) { @@ -251,7 +252,7 @@ function useHoverOverlay({ maybeClearRefTimeout(delayHideTimeoutRef); maybeClearRefTimeout(delayOpenTimeoutRef); - if (delay === 0) { + if (delay === 0 || NODE_ENV === 'test') { setIsVisible(true); return; } diff --git a/static/app/views/dashboards/detail.spec.tsx b/static/app/views/dashboards/detail.spec.tsx index 002d58c5a47a2d..c9a862b4e675e6 100644 --- a/static/app/views/dashboards/detail.spec.tsx +++ b/static/app/views/dashboards/detail.spec.tsx @@ -117,6 +117,18 @@ describe('Dashboards > Detail', () => { }; } + /** + * Clicks the edit dashboard button and waits for a known button to be visible. + * Note that this bypasses hover state to avoid overlays intercepting clicks. + */ + async function activateDashboardEditMode() { + const button = await screen.findByRole('button', {name: 'edit-dashboard'}); + act(() => { + button.click(); + }); + await screen.findByRole('button', {name: 'Save and Finish'}); + } + window.IntersectionObserver = MockIntersectionObserver as any; describe('prebuilt dashboards', () => { @@ -502,8 +514,7 @@ describe('Dashboards > Detail', () => { await waitFor(() => expect(mockVisit).toHaveBeenCalledTimes(1)); - // Enter edit mode. - await userEvent.click(await screen.findByRole('button', {name: 'edit-dashboard'})); + await activateDashboardEditMode(); // Remove the second and third widgets await userEvent.click( @@ -581,8 +592,7 @@ describe('Dashboards > Detail', () => { }) ); - // Enter edit mode. - await userEvent.click(await screen.findByRole('button', {name: 'edit-dashboard'})); + await activateDashboardEditMode(); expect(await screen.findByRole('button', {name: 'Add Widget'})).toBeInTheDocument(); }); @@ -630,8 +640,7 @@ describe('Dashboards > Detail', () => { }) ); - // Enter edit mode. - await userEvent.click(await screen.findByRole('button', {name: 'edit-dashboard'})); + await activateDashboardEditMode(); expect(screen.queryByRole('button', {name: 'Add widget'})).not.toBeInTheDocument(); }); @@ -725,7 +734,7 @@ describe('Dashboards > Detail', () => { organization: initialData.organization, }); - await userEvent.click(await screen.findByRole('button', {name: 'edit-dashboard'})); + await activateDashboardEditMode(); await userEvent.click(await screen.findByText('Save and Finish')); expect(screen.getByRole('button', {name: 'edit-dashboard'})).toBeInTheDocument(); @@ -767,7 +776,7 @@ describe('Dashboards > Detail', () => { organization: initialData.organization, }); - await userEvent.click(await screen.findByRole('button', {name: 'edit-dashboard'})); + await activateDashboardEditMode(); const widget = (await screen.findByText('First Widget')).closest( '.react-grid-item' ) as HTMLElement; @@ -811,7 +820,7 @@ describe('Dashboards > Detail', () => { organization: initialData.organization, }); - await userEvent.click(await screen.findByRole('button', {name: 'edit-dashboard'})); + await activateDashboardEditMode(); await userEvent.click(await screen.findByText('Cancel')); expect(window.confirm).not.toHaveBeenCalled(); diff --git a/static/app/views/discover/table/quickContext/quickContextHovercard.spec.tsx b/static/app/views/discover/table/quickContext/quickContextHovercard.spec.tsx index 12db39d7c6e77f..4af0f35ac59f22 100644 --- a/static/app/views/discover/table/quickContext/quickContextHovercard.spec.tsx +++ b/static/app/views/discover/table/quickContext/quickContextHovercard.spec.tsx @@ -93,7 +93,7 @@ describe('Quick Context', () => { await userEvent.hover(screen.getByText('Text from Child')); - expect(await screen.findByText(/Issue/i)).toBeInTheDocument(); + expect(await screen.findByText('Issue', {exact: true})).toBeInTheDocument(); expect(screen.getByText(/SENTRY-VVY/i)).toBeInTheDocument(); expect( screen.getByTestId('quick-context-hover-header-copy-button') diff --git a/static/app/views/projectInstall/createProject.spec.tsx b/static/app/views/projectInstall/createProject.spec.tsx index 1eacb535039b7f..5d1a32b7cf788b 100644 --- a/static/app/views/projectInstall/createProject.spec.tsx +++ b/static/app/views/projectInstall/createProject.spec.tsx @@ -781,7 +781,9 @@ describe('CreateProject', () => { expect(await screen.findByText('Channel not found')).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Create Project'})).toBeDisabled(); await userEvent.hover(screen.getByRole('button', {name: 'Create Project'})); - expect(await screen.findByText('Channel not found')).toBeInTheDocument(); + await waitFor(() => + expect(screen.getAllByText('Channel not found')).toHaveLength(2) + ); await userEvent.click(screen.getByLabelText('Clear choices')); await userEvent.hover(screen.getByRole('button', {name: 'Create Project'})); expect( From d4ad198349f17a28e78f28b3a2d10daf3c58c197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Dorfmeister=20=F0=9F=94=AE?= Date: Tue, 7 Apr 2026 15:22:11 +0200 Subject: [PATCH 09/18] ref(tsc): refactor self-contained endpoints that need response headers to apiOptions (#112347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit as discussed in last week’s TSC, we need to start moving endpoints over to `apiOptions` so that we can re-use caches. the quickest win would be to implement `useApiQuery` with `apiOptions` internally, and to get there, we need to migrate endpoints that now need `getResponseHeader` over to `apiOptions` because those headers are exposed differently. this PR takes the first couple of endpoints that are (mostly) self-contained (no other usages of the url found) and moves them over. I’ve also exposed `selectJsonWithHeaders` because the default impl only selects `json` without `headers`. I plan to tackle the other occurrences with an endpoint-by-endpoint approach, as we need to identify all places where an endpoint is used (e.g. `invalidateQueries` or `getApiQueryData` or `setApiQueryData`) and migrate them together. --- static/AGENTS.md | 26 ++++++ static/app/utils/api/apiOptions.ts | 52 ++++++++++- .../replays/hooks/useDeadRageSelectors.tsx | 51 +++++------ .../alerts/list/rules/alertRulesList.tsx | 58 ++++++------ .../alerts/rules/issue/details/issuesList.tsx | 47 +++++----- static/app/views/discover/landing.tsx | 30 +++---- .../account/accountNotificationFineTuning.tsx | 32 ++++--- .../organizationTeams/teamMembers.tsx | 89 +++++++++---------- .../projectFilters/groupTombstones.tsx | 32 +++---- 9 files changed, 244 insertions(+), 173 deletions(-) diff --git a/static/AGENTS.md b/static/AGENTS.md index f5a441d194e866..e95be6804f40c5 100644 --- a/static/AGENTS.md +++ b/static/AGENTS.md @@ -60,6 +60,32 @@ const query = useQuery( Existing code might use `useApiQuery` from `sentry/utils/queryClient` — prefer `apiOptions` for new code. +#### Accessing response headers (pagination, hit counts) + +By default, `apiOptions` selects only the JSON body from the response. If you need response headers (e.g., `Link` for pagination or `X-Hits` / `X-Max-Hits` for total counts), override `select` with `selectJsonWithHeaders`: + +```typescript +import {useQuery} from '@tanstack/react-query'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; + +const {data} = useQuery({ + ...apiOptions.as()('/organizations/$organizationIdOrSlug/items/', { + path: {organizationIdOrSlug: organization.slug}, + query: {cursor, per_page: 25}, + staleTime: 0, + }), + select: selectJsonWithHeaders, +}); + +// data is ApiResponse — an object with `json` and `headers` +const items = data?.json ?? []; +const pageLinks = data?.headers.Link; // string | undefined +const totalHits = data?.headers['X-Hits']; // number | undefined +const maxHits = data?.headers['X-Max-Hits']; // number | undefined +``` + +Note that `X-Hits` and `X-Max-Hits` are already parsed to `number | undefined` — no `parseInt` needed. + ## General Frontend Rules 1. NO new Reflux stores diff --git a/static/app/utils/api/apiOptions.ts b/static/app/utils/api/apiOptions.ts index 378c28ca7ad550..2271645782795f 100644 --- a/static/app/utils/api/apiOptions.ts +++ b/static/app/utils/api/apiOptions.ts @@ -23,6 +23,12 @@ type PathParamOptions = ? {path?: never} : {path: Record, string | number> | SkipToken}; +const selectJson = (data: ApiResponse) => data.json; + +export const selectJsonWithHeaders = ( + data: ApiResponse +): ApiResponse => data; + function _apiOptions< TManualData = never, TApiPath extends KnownApiUrls = KnownApiUrls, @@ -46,7 +52,7 @@ function _apiOptions< queryFn: pathParams === skipToken ? skipToken : apiFetch, enabled: pathParams !== skipToken, staleTime, - select: data => data.json, + select: selectJson, }); } @@ -86,6 +92,50 @@ function _apiOptionsInfinite< }); } +/** + * Type-safe factory for TanStack Query options that hit Sentry API endpoints. + * + * By default, `select` extracts the JSON body. To also access response headers + * (e.g. `Link` for pagination), override with `selectJsonWithHeaders`. + * + * @example Basic usage + * ```ts + * const query = useQuery( + * apiOptions.as()('/organizations/$organizationIdOrSlug/projects/', { + * path: {organizationIdOrSlug: organization.slug}, + * staleTime: 30_000, + * }) + * ); + * // query.data is Project[] + * ``` + * + * @example Conditional fetching + * ```ts + * const query = useQuery( + * apiOptions.as()('/organizations/$organizationIdOrSlug/projects/$projectIdOrSlug/', { + * path: projectSlug + * ? {organizationIdOrSlug: organization.slug, projectIdOrSlug: projectSlug} + * : skipToken, + * staleTime: 30_000, + * }) + * ); + * ``` + * + * @example With response headers (pagination) + * ```ts + * const {data} = useQuery({ + * ...apiOptions.as()('/organizations/$organizationIdOrSlug/items/', { + * path: {organizationIdOrSlug: organization.slug}, + * query: {cursor, per_page: 25}, + * staleTime: 0, + * }), + * select: selectJsonWithHeaders, + * }); + * // data is ApiResponse + * const items = data?.json ?? []; + * const pageLinks = data?.headers.Link; + * ``` + */ export const apiOptions = { as: () => diff --git a/static/app/utils/replays/hooks/useDeadRageSelectors.tsx b/static/app/utils/replays/hooks/useDeadRageSelectors.tsx index 3169f345ca4386..0503534dd7f836 100644 --- a/static/app/utils/replays/hooks/useDeadRageSelectors.tsx +++ b/static/app/utils/replays/hooks/useDeadRageSelectors.tsx @@ -1,5 +1,6 @@ -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {useApiQuery} from 'sentry/utils/queryClient'; +import {useQuery} from '@tanstack/react-query'; + +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {decodeList, decodeScalar} from 'sentry/utils/queryString'; import {hydratedSelectorData} from 'sentry/utils/replays/hydrateSelectorData'; import {useLocation} from 'sentry/utils/useLocation'; @@ -14,37 +15,37 @@ export function useDeadRageSelectors(params: DeadRageSelectorQueryParams) { const location = useLocation(); const {query} = location; - const {isPending, isError, error, data, getResponseHeader} = - useApiQuery( - [ - getApiUrl('/organizations/$organizationIdOrSlug/replay-selectors/', { - path: {organizationIdOrSlug: organization.slug}, - }), - { - query: { - query: '!count_dead_clicks:0', - cursor: params.cursor, - environment: decodeList(query.environment), - project: query.project, - statsPeriod: query.statsPeriod, - start: decodeScalar(query.start), - end: decodeScalar(query.end), - per_page: params.per_page, - sort: query[params.prefix + 'sort'] ?? params.sort, - }, + const {isPending, isError, error, data} = useQuery({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/replay-selectors/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + query: '!count_dead_clicks:0', + cursor: params.cursor, + environment: decodeList(query.environment), + project: query.project, + statsPeriod: query.statsPeriod, + start: decodeScalar(query.start), + end: decodeScalar(query.end), + per_page: params.per_page, + sort: query[params.prefix + 'sort'] ?? params.sort, }, - ], - {staleTime: Infinity, enabled: params.enabled} - ); + staleTime: Infinity, + } + ), + select: selectJsonWithHeaders, + enabled: params.enabled, + }); return { isLoading: isPending, isError, error, data: hydratedSelectorData( - data ? data.data : [], + data ? data.json.data : [], params.isWidgetData ? params.sort?.replace(/^-/, '') : null ), - pageLinks: getResponseHeader?.('Link') ?? undefined, + pageLinks: data?.headers.Link, }; } diff --git a/static/app/views/alerts/list/rules/alertRulesList.tsx b/static/app/views/alerts/list/rules/alertRulesList.tsx index b03e27616335ed..9864bb1b8dbb40 100644 --- a/static/app/views/alerts/list/rules/alertRulesList.tsx +++ b/static/app/views/alerts/list/rules/alertRulesList.tsx @@ -1,5 +1,6 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; +import {useQuery} from '@tanstack/react-query'; import type {Location} from 'history'; import {Alert} from '@sentry/scraps/alert'; @@ -22,12 +23,11 @@ import {IconArrow} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Project} from 'sentry/types/project'; import {defined} from 'sentry/utils'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {uniq} from 'sentry/utils/array/uniq'; import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry'; import {Projects} from 'sentry/utils/projects'; -import type {ApiQueryKey} from 'sentry/utils/queryClient'; -import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient'; +import {useQueryClient} from 'sentry/utils/queryClient'; import {useRouteAnalyticsEventNames} from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames'; import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; import {useApi} from 'sentry/utils/useApi'; @@ -45,7 +45,7 @@ import {RuleListRow} from './row'; type SortField = 'date_added' | 'name' | ['incident_status', 'date_triggered']; const defaultSort: SortField = ['incident_status', 'date_triggered']; -function getAlertListQueryKey(orgSlug: string, query: Location['query']): ApiQueryKey { +function getAlertListQueryParams(query: Location['query']) { const queryParams = {...query}; queryParams.expand = ['latestIncident', 'lastTriggered']; queryParams.team = getTeamParams(queryParams.team!); @@ -54,12 +54,18 @@ function getAlertListQueryKey(orgSlug: string, query: Location['query']): ApiQue queryParams.sort = defaultSort; } - return [ - getApiUrl('/organizations/$organizationIdOrSlug/combined-rules/', { + return queryParams; +} + +function getAlertListApiOptions(orgSlug: string, query: Location['query']) { + return apiOptions.as>()( + '/organizations/$organizationIdOrSlug/combined-rules/', + { path: {organizationIdOrSlug: orgSlug}, - }), - {query: queryParams}, - ]; + query: getAlertListQueryParams(query), + staleTime: 0, + } + ); } const DataConsentBanner = HookOrDefault({ @@ -82,18 +88,12 @@ export default function AlertRulesList() { }); // Fetch alert rules - const { - data: ruleListResponse = [], - refetch, - getResponseHeader, - isPending, - isError, - } = useApiQuery>( - getAlertListQueryKey(organization.slug, location.query), - { - staleTime: 0, - } - ); + const alertListOptions = getAlertListApiOptions(organization.slug, location.query); + const {data, refetch, isPending, isError} = useQuery({ + ...alertListOptions, + select: selectJsonWithHeaders, + }); + const ruleListResponse = data?.json ?? []; const handleChangeFilter = (activeFilters: string[]) => { const {cursor: _cursor, page: _page, ...currentQuery} = location.query; @@ -166,11 +166,15 @@ export default function AlertRulesList() { try { await api.requestPromise(deleteEndpoints[rule.type], {method: 'DELETE'}); - setApiQueryData>( - queryClient, - getAlertListQueryKey(organization.slug, location.query), - data => data?.filter(r => r?.id !== rule.id && r?.type !== rule.type) - ); + queryClient.setQueryData(alertListOptions.queryKey, previous => { + if (!previous) { + return previous; + } + return { + ...previous, + json: previous.json.filter(r => r?.id !== rule.id && r?.type !== rule.type), + }; + }); refetch(); addSuccessMessage(t('Deleted rule')); } catch (_err) { @@ -194,7 +198,7 @@ export default function AlertRulesList() { : rule.projects ) ); - const ruleListPageLinks = getResponseHeader?.('Link'); + const ruleListPageLinks = data?.headers.Link; const sort: {asc: boolean; field: SortField} = { asc: location.query.asc === '1', diff --git a/static/app/views/alerts/rules/issue/details/issuesList.tsx b/static/app/views/alerts/rules/issue/details/issuesList.tsx index dcc4d4628faf08..52ec186242a855 100644 --- a/static/app/views/alerts/rules/issue/details/issuesList.tsx +++ b/static/app/views/alerts/rules/issue/details/issuesList.tsx @@ -1,6 +1,7 @@ import {Fragment} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; +import {useQuery} from '@tanstack/react-query'; import {Flex} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; @@ -15,10 +16,10 @@ import {t} from 'sentry/locale'; import type {IssueAlertRule} from 'sentry/types/alerts'; import type {Group} from 'sentry/types/group'; import type {Project} from 'sentry/types/project'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {getMessage, getTitle} from 'sentry/utils/events'; import type {FeedbackIssue} from 'sentry/utils/feedback/types'; -import {useApiQuery} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; import {useOrganization} from 'sentry/utils/useOrganization'; import {makeFeedbackPathname} from 'sentry/views/feedback/pathnames'; @@ -45,25 +46,15 @@ export function AlertRuleIssuesList({ cursor, }: Props) { const organization = useOrganization(); - const { - data: groupHistory, - getResponseHeader, - isPending, - isError, - error, - } = useApiQuery( - [ - getApiUrl( - '/projects/$organizationIdOrSlug/$projectIdOrSlug/rules/$ruleId/group-history/', - { - path: { - organizationIdOrSlug: organization.slug, - projectIdOrSlug: project.slug, - ruleId: rule.id, - }, - } - ), + const {data, isPending, error} = useQuery({ + ...apiOptions.as()( + '/projects/$organizationIdOrSlug/$projectIdOrSlug/rules/$ruleId/group-history/', { + path: { + organizationIdOrSlug: organization.slug, + projectIdOrSlug: project.slug, + ruleId: rule.id, + }, query: { per_page: 10, ...(period && {statsPeriod: period}), @@ -72,15 +63,17 @@ export function AlertRuleIssuesList({ utc, cursor, }, - }, - ], - {staleTime: 0} - ); + staleTime: 0, + } + ), + select: selectJsonWithHeaders, + }); + const groupHistory = data?.json; - if (isError) { + if (error instanceof RequestError) { return ( ); } @@ -140,7 +133,7 @@ export function AlertRuleIssuesList({ })} - + ); diff --git a/static/app/views/discover/landing.tsx b/static/app/views/discover/landing.tsx index 0c14426e395671..2e79098e64748f 100644 --- a/static/app/views/discover/landing.tsx +++ b/static/app/views/discover/landing.tsx @@ -1,4 +1,5 @@ import styled from '@emotion/styled'; +import {useQuery} from '@tanstack/react-query'; import {Alert} from '@sentry/scraps/alert'; import {LinkButton} from '@sentry/scraps/button'; @@ -19,10 +20,9 @@ import {t, tct} from 'sentry/locale'; import type {SelectValue} from 'sentry/types/core'; import type {NewQuery, SavedQuery} from 'sentry/types/organization'; import {trackAnalytics} from 'sentry/utils/analytics'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {EventView} from 'sentry/utils/discover/eventView'; import {getDiscoverLandingUrl} from 'sentry/utils/discover/urls'; -import {useApiQuery} from 'sentry/utils/queryClient'; import {decodeScalar} from 'sentry/utils/queryString'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import {useLocation} from 'sentry/utils/useLocation'; @@ -114,19 +114,17 @@ const useDiscoverLandingQuery = (renderPrebuilt: boolean) => { delete queryParams.cursor; } - return useApiQuery( - [ - getApiUrl('/organizations/$organizationIdOrSlug/discover/saved/', { - path: {organizationIdOrSlug: organization.slug}, - }), + return useQuery({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/discover/saved/', { + path: {organizationIdOrSlug: organization.slug}, query: queryParams, - }, - ], - { - staleTime: 0, - } - ); + staleTime: 0, + } + ), + select: selectJsonWithHeaders, + }); }; const RENDER_PREBUILT_KEY = 'discover-render-prebuilt'; @@ -146,12 +144,12 @@ function DiscoverLanding() { const { status, error, - data: savedQueries = [], - getResponseHeader, + data: savedQueriesResponse, refetch: refreshSavedQueries, } = useDiscoverLandingQuery(renderPrebuilt); - const savedQueriesPageLinks = getResponseHeader?.('Link'); + const savedQueries = savedQueriesResponse?.json ?? []; + const savedQueriesPageLinks = savedQueriesResponse?.headers.Link; const to = makeDiscoverPathname({ path: `/homepage/`, diff --git a/static/app/views/settings/account/accountNotificationFineTuning.tsx b/static/app/views/settings/account/accountNotificationFineTuning.tsx index be42740563547a..eaeaa3ebb42a38 100644 --- a/static/app/views/settings/account/accountNotificationFineTuning.tsx +++ b/static/app/views/settings/account/accountNotificationFineTuning.tsx @@ -1,4 +1,5 @@ import {Fragment} from 'react'; +import {useQuery} from '@tanstack/react-query'; import {parseAsString, useQueryState} from 'nuqs'; import {z} from 'zod'; @@ -20,6 +21,7 @@ import {OrganizationsStore} from 'sentry/stores/organizationsStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {Project} from 'sentry/types/project'; import type {UserEmail} from 'sentry/types/user'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {fetchMutation, keepPreviousData, useApiQuery} from 'sentry/utils/queryClient'; import {ACCOUNT_NOTIFICATION_FIELDS} from 'sentry/views/settings/account/notifications/fields'; @@ -113,26 +115,22 @@ export function AccountNotificationFineTuning() { (organizations.length === 1 ? organizations[0]?.id : undefined); const { - data: projects, + data: projectsResp, isPending: isPendingProjects, isError: isErrorProjects, - getResponseHeader: getProjectsResponseHeader, - } = useApiQuery( - [ - getApiUrl('/projects/'), - { - query: { - organizationId, - cursor, - query, - }, + } = useQuery({ + ...apiOptions.as()('/projects/', { + query: { + organizationId, + cursor, + query, }, - ], - { staleTime: 0, - enabled: Boolean(organizationId), - } - ); + }), + enabled: Boolean(organizationId), + select: selectJsonWithHeaders, + }); + const projects = projectsResp?.json; const { data: emails = [], @@ -234,7 +232,7 @@ export function AccountNotificationFineTuning() { )} - {projects && } + {projects && } ); } diff --git a/static/app/views/settings/organizationTeams/teamMembers.tsx b/static/app/views/settings/organizationTeams/teamMembers.tsx index f23ebecd6cdc99..67b4fa1aed6429 100644 --- a/static/app/views/settings/organizationTeams/teamMembers.tsx +++ b/static/app/views/settings/organizationTeams/teamMembers.tsx @@ -1,6 +1,6 @@ import {Fragment, useMemo, useState} from 'react'; import styled from '@emotion/styled'; -import {keepPreviousData} from '@tanstack/react-query'; +import {keepPreviousData, useQuery} from '@tanstack/react-query'; import {UserAvatar} from '@sentry/scraps/avatar'; import { @@ -28,14 +28,9 @@ import {TeamRoleColumnLabel} from 'sentry/components/teamRoleUtils'; import {IconUser} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Member, Organization, Team, TeamMember} from 'sentry/types/organization'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import { - setApiQueryData, - useApiQuery, - useMutation, - useQueryClient, - type ApiQueryKey, -} from 'sentry/utils/queryClient'; +import {useApiQuery, useMutation, useQueryClient} from 'sentry/utils/queryClient'; import {useApi} from 'sentry/utils/useApi'; import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; import {useLocation} from 'sentry/utils/useLocation'; @@ -51,7 +46,7 @@ import {ProjectPermissionAlert} from 'sentry/views/settings/project/projectPermi import {getButtonHelpText} from './utils'; -function getTeamMembersQueryKey({ +function getTeamMembersApiOptions({ organization, teamId, location, @@ -59,18 +54,18 @@ function getTeamMembersQueryKey({ location: ReturnType; organization: Organization; teamId: string; -}): ApiQueryKey { - return [ - getApiUrl(`/teams/$organizationIdOrSlug/$teamIdOrSlug/members/`, { - path: {organizationIdOrSlug: organization.slug, teamIdOrSlug: teamId}, - }), +}) { + return apiOptions.as()( + '/teams/$organizationIdOrSlug/$teamIdOrSlug/members/', { + path: {organizationIdOrSlug: organization.slug, teamIdOrSlug: teamId}, query: { cursor: location.query.cursor, query: location.query.query, }, - }, - ]; + staleTime: 30_000, + } + ); } function AddMemberDropdown({ @@ -204,19 +199,17 @@ export default function TeamMembers() { const {team} = useTeamDetailsOutlet(); const { - data: teamMembers = [], + data: teamMembersResp, isError: isTeamMembersError, isLoading: isTeamMembersLoading, refetch: refetchTeamMembers, - getResponseHeader: getTeamMemberResponseHeader, - } = useApiQuery( - getTeamMembersQueryKey({organization, teamId: team.slug, location}), - { - staleTime: 30_000, - } - ); + } = useQuery({ + ...getTeamMembersApiOptions({organization, teamId: team.slug, location}), + select: selectJsonWithHeaders, + }); + const teamMembers = teamMembersResp?.json ?? []; - const teamMembersPageLinks = getTeamMemberResponseHeader?.('Link'); + const teamMembersPageLinks = teamMembersResp?.headers.Link; const hasOrgWriteAccess = hasEveryAccess(['org:write'], {organization, team}); const hasTeamAdminAccess = hasEveryAccess(['team:admin'], {organization, team}); @@ -231,14 +224,16 @@ export default function TeamMembers() { }); }, onSuccess: (_data, variables) => { - setApiQueryData( - queryClient, - getTeamMembersQueryKey({organization, teamId: team.slug, location}), + queryClient.setQueryData( + getTeamMembersApiOptions({organization, teamId: team.slug, location}).queryKey, existingData => { if (!existingData) { return existingData; } - return existingData.filter(member => member.id !== variables.memberId); + return { + ...existingData, + json: existingData.json.filter(member => member.id !== variables.memberId), + }; } ); addSuccessMessage(t('Successfully removed member from team.')); @@ -262,24 +257,26 @@ export default function TeamMembers() { }, onSuccess: (_data, variables) => { addSuccessMessage(t('Successfully changed role for team member.')); - setApiQueryData( - queryClient, - getTeamMembersQueryKey({organization, teamId: team.slug, location}), + queryClient.setQueryData( + getTeamMembersApiOptions({organization, teamId: team.slug, location}).queryKey, existingData => { if (!existingData) { return existingData; } - return existingData.map(member => { - if (member.id === variables.memberId) { - return { - ...member, - teamRole: variables.newRole, - }; - } + return { + ...existingData, + json: existingData.json.map(member => { + if (member.id === variables.memberId) { + return { + ...member, + teamRole: variables.newRole, + }; + } - return member; - }); + return member; + }), + }; } ); }, @@ -299,14 +296,16 @@ export default function TeamMembers() { }); }, onSuccess: (_data, {orgMember}) => { - setApiQueryData( - queryClient, - getTeamMembersQueryKey({organization, teamId: team.slug, location}), + queryClient.setQueryData( + getTeamMembersApiOptions({organization, teamId: team.slug, location}).queryKey, existingData => { if (!existingData) { return existingData; } - return existingData.concat([orgMember]); + return { + ...existingData, + json: existingData.json.concat([orgMember]), + }; } ); addSuccessMessage(t('Successfully added member to team.')); diff --git a/static/app/views/settings/project/projectFilters/groupTombstones.tsx b/static/app/views/settings/project/projectFilters/groupTombstones.tsx index c63689589d9d73..574e29203779fd 100644 --- a/static/app/views/settings/project/projectFilters/groupTombstones.tsx +++ b/static/app/views/settings/project/projectFilters/groupTombstones.tsx @@ -1,5 +1,6 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; +import {useQuery} from '@tanstack/react-query'; import {UserAvatar} from '@sentry/scraps/avatar'; import {Button} from '@sentry/scraps/button'; @@ -22,9 +23,8 @@ import {t} from 'sentry/locale'; import type {GroupTombstone} from 'sentry/types/group'; import type {Project} from 'sentry/types/project'; import {defined} from 'sentry/utils'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {getMessage, getTitle} from 'sentry/utils/events'; -import {useApiQuery} from 'sentry/utils/queryClient'; import {useApi} from 'sentry/utils/useApi'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -122,21 +122,23 @@ export function GroupTombstones({project}: GroupTombstonesProps) { const location = useLocation(); const organization = useOrganization(); const { - data: tombstones, + data: tombstonesResp, isPending, isError, refetch, - getResponseHeader, - } = useApiQuery( - [ - getApiUrl(`/projects/$organizationIdOrSlug/$projectIdOrSlug/tombstones/`, { + } = useQuery({ + ...apiOptions.as()( + '/projects/$organizationIdOrSlug/$projectIdOrSlug/tombstones/', + { path: {organizationIdOrSlug: organization.slug, projectIdOrSlug: project.slug}, - }), - {query: {...location.query}}, - ], - {staleTime: 0} - ); - const tombstonesPageLinks = getResponseHeader?.('Link'); + query: {...location.query}, + staleTime: 0, + } + ), + select: selectJsonWithHeaders, + }); + const tombstones = tombstonesResp?.json; + const tombstonesPageLinks = tombstonesResp?.headers.Link; const handleUndiscard = (tombstoneId: GroupTombstone['id']) => { api @@ -185,10 +187,10 @@ export function GroupTombstones({project}: GroupTombstonesProps) { {t('Member')}, , ]} - isEmpty={!tombstones.length} + isEmpty={!tombstones?.length} emptyMessage={t('You have no discarded issues')} > - {tombstones.map(data => ( + {tombstones?.map(data => ( Date: Tue, 7 Apr 2026 10:23:03 -0400 Subject: [PATCH 10/18] feat(tracemetrics): Update ArithmeticBuilder to allow for REFERENCE tokens (#111956) To be able to do equations using series, we need to be able to support UI that will allow us to reference other series definitions. To do this I've added a `REFERENCE` token type which will be used in conjunction with an argument passed in, `references`, which determines the available references. This required me to update the grammar, validators, and tokenizer to have the right check for this "references" condition (i.e. if it's not in the set, it's not a reference and it will fall back to free text) The changes also follow closely to the code path for functions, so places where it checks for functions as valid terms should also check for references. These are used to update things like the autoformatting and cursor placement when switching from freetext to a reference token. I also changed some of the internals for how we handle `static/app/components/arithmeticBuilder/action.tsx` because I want the expression to be able to re-evaluate if the references change. If a reference is removed, the equation should also update and render the old reference as free text. Since there's no UI using this at the moment, I've opted to render it in Storybook --- .../components/arithmeticBuilder/action.tsx | 34 ++- .../arithmeticBuilder.stories.tsx | 89 ++++++ .../components/arithmeticBuilder/context.tsx | 1 + .../arithmeticBuilder/expression.tsx | 4 +- .../arithmeticBuilder/grammar.pegjs | 7 +- .../arithmeticBuilder/index.spec.tsx | 17 +- .../components/arithmeticBuilder/index.tsx | 19 ++ .../arithmeticBuilder/token/deleteButton.tsx | 49 ++++ .../arithmeticBuilder/token/freeText.tsx | 93 +++--- .../arithmeticBuilder/token/function.tsx | 42 +-- .../arithmeticBuilder/token/grid.tsx | 13 + .../arithmeticBuilder/token/index.spec.tsx | 43 +++ .../arithmeticBuilder/token/index.tsx | 17 ++ .../arithmeticBuilder/token/literal.tsx | 98 +------ .../arithmeticBuilder/token/reference.tsx | 270 ++++++++++++++++++ .../arithmeticBuilder/token/styles.tsx | 50 ++++ .../arithmeticBuilder/tokenizer.spec.tsx | 33 ++- .../arithmeticBuilder/tokenizer.tsx | 38 ++- .../arithmeticBuilder/validator.spec.tsx | 80 +++++- .../arithmeticBuilder/validator.tsx | 34 ++- 20 files changed, 830 insertions(+), 201 deletions(-) create mode 100644 static/app/components/arithmeticBuilder/arithmeticBuilder.stories.tsx create mode 100644 static/app/components/arithmeticBuilder/token/deleteButton.tsx create mode 100644 static/app/components/arithmeticBuilder/token/reference.tsx create mode 100644 static/app/components/arithmeticBuilder/token/styles.tsx diff --git a/static/app/components/arithmeticBuilder/action.tsx b/static/app/components/arithmeticBuilder/action.tsx index 5c0a864edabd1b..494fb1dbabc0ab 100644 --- a/static/app/components/arithmeticBuilder/action.tsx +++ b/static/app/components/arithmeticBuilder/action.tsx @@ -51,11 +51,13 @@ function isArithmeticBuilderReplaceAction( interface UseArithmeticBuilderActionOptions { initialExpression: string; + references?: Set; updateExpression?: (expression: Expression) => void; } export function useArithmeticBuilderAction({ initialExpression, + references, updateExpression, }: UseArithmeticBuilderActionOptions): { dispatch: (action: ArithmeticBuilderAction) => void; @@ -64,30 +66,38 @@ export function useArithmeticBuilderAction({ focusOverride: FocusOverride | null; }; } { - const [expression, setExpression] = useState(() => new Expression(initialExpression)); + const [expressionString, setExpressionString] = useState(initialExpression); const [focusOverride, setFocusOverride] = useState(null); + // Recreate the Expression when the string or references change because + // a reference change may invalidate some of the current references and turn + // them into free text tokens. + const expression = useMemo( + () => new Expression(expressionString, references), + [expressionString, references] + ); + const dispatch = useCallback( (action: ArithmeticBuilderAction) => { if (isArithmeticBuilderUpdateResetFocusOverrideAction(action)) { setFocusOverride(null); } else if (isArithmeticBuilderDeleteAction(action)) { - const newExpression = deleteToken(expression.text, action); - updateExpression?.(newExpression); - setExpression(newExpression); + const newText = deleteTokenText(expressionString, action); + setExpressionString(newText); + updateExpression?.(new Expression(newText, references)); if (defined(action.focusOverride)) { setFocusOverride(action.focusOverride); } } else if (isArithmeticBuilderReplaceAction(action)) { - const newExpression = replaceToken(expression.text, action); - updateExpression?.(newExpression); - setExpression(newExpression); + const newText = replaceTokenText(expressionString, action); + setExpressionString(newText); + updateExpression?.(new Expression(newText, references)); if (defined(action.focusOverride)) { setFocusOverride(action.focusOverride); } } }, - [expression.text, updateExpression] + [expressionString, references, updateExpression] ); const state = useMemo( @@ -101,14 +111,14 @@ export function useArithmeticBuilderAction({ return {state, dispatch}; } -function deleteToken(text: string, action: ArithmeticBuilderDeleteAction) { +function deleteTokenText(text: string, action: ArithmeticBuilderDeleteAction): string { const [head, tail] = queryHeadTail(text, action.token); - return new Expression(removeExcessWhitespaceFromParts(head, tail)); + return removeExcessWhitespaceFromParts(head, tail); } -function replaceToken(text: string, action: ArithmeticBuilderReplaceAction) { +function replaceTokenText(text: string, action: ArithmeticBuilderReplaceAction): string { const [head, tail] = queryHeadTail(text, action.token); - return new Expression(removeExcessWhitespaceFromParts(head, action.text, tail)); + return removeExcessWhitespaceFromParts(head, action.text, tail); } function queryHeadTail(expression: string, token: Token): [string, string] { diff --git a/static/app/components/arithmeticBuilder/arithmeticBuilder.stories.tsx b/static/app/components/arithmeticBuilder/arithmeticBuilder.stories.tsx new file mode 100644 index 00000000000000..76120e6025cc92 --- /dev/null +++ b/static/app/components/arithmeticBuilder/arithmeticBuilder.stories.tsx @@ -0,0 +1,89 @@ +import {Fragment, useCallback, useEffect, useState} from 'react'; + +import {ArithmeticBuilder} from 'sentry/components/arithmeticBuilder'; +import {Expression} from 'sentry/components/arithmeticBuilder/expression'; +import {ErrorBoundary} from 'sentry/components/errorBoundary'; +import * as Storybook from 'sentry/stories'; + +export default Storybook.story('ArithmeticBuilder', story => { + story('With References', () => { + const [expression, setExpression] = useState('A + B'); + const [isValid, setIsValid] = useState(true); + const [references, setReferences] = useState(new Set(['A', 'B', 'C'])); + const [parseError, setParseError] = useState(''); + + const onExpressionChange = useCallback((expr: Expression) => { + setExpression(expr.text); + }, []); + + // Explicitly check the new expression for validity since references + // changing is a responibility of the caller. + useEffect(() => { + setIsValid(new Expression(expression, references).isValid); + }, [expression, references]); + + return ( + +

+ Define references as a JSON array of strings below, then use them in the + expression. If references are present, then they will take priority over + aggregations and only suggest references and operators when typing. +

+ +

+ If a character appears that is not a reference, then it will be treated as a + free text token. +

+ +

+ If during typing, the string matches a single reference, we will automatically + select that reference. Otherwise we will continue to suggest references since + there are multiple options. +

+ +