Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
ca71e01
feat: integrate modal support for external mapping form with improved…
TkDodo Mar 10, 2026
cec4461
fix: async select
TkDodo Mar 10, 2026
d3f9425
ref: mutation endpoint
TkDodo Mar 10, 2026
99e2055
ref: immutable updates
TkDodo Mar 10, 2026
81e694f
fix: defaultOptions
TkDodo Mar 10, 2026
25afec0
fix: refetch data after change
TkDodo Mar 10, 2026
8d78d22
fix: error handling
TkDodo Mar 10, 2026
ef60198
ref: type improvements
TkDodo Mar 10, 2026
3bc8836
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 10, 2026
7647a0c
ref: avoid type assertion
TkDodo Mar 10, 2026
406f488
fix: stabilize selectors
TkDodo Mar 10, 2026
356c85d
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 10, 2026
cb12e87
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 10, 2026
6856881
fix: keep track of team slugs with a ref
TkDodo Mar 10, 2026
d2b88f2
ref: remove sentryName
TkDodo Mar 10, 2026
b9a5154
ref: increase staleTime
TkDodo Mar 10, 2026
a78442e
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 10, 2026
0415467
refactor(select): enhance type definitions and improve option matchin…
TkDodo Mar 10, 2026
6d100d5
ref: remove defaultOptions
TkDodo Mar 10, 2026
456402f
ref: fix options type
TkDodo Mar 11, 2026
7297a0c
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 11, 2026
94295d3
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 11, 2026
89ecc6d
ref: remove defaultOptions test as defaultOptions don't exist anymore
TkDodo Mar 11, 2026
b4f12e4
ref: remove leftover
TkDodo Mar 11, 2026
37ddb5c
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 11, 2026
001b66e
ref: update form submission error handling to use formApi
TkDodo Mar 11, 2026
8e081de
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 11, 2026
0bcf7bb
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 12, 2026
31e731b
fix: guard against non-request errors
TkDodo Mar 12, 2026
7c15a60
:hammer_and_wrench: apply pre-commit fixes
getsantry[bot] Mar 12, 2026
3b60078
fix: merge conflicts
TkDodo Mar 12, 2026
c4ea190
ref: improve findOption types
TkDodo Mar 12, 2026
f561685
fix: imports
TkDodo Mar 12, 2026
6b64715
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 12, 2026
a75a492
ref: inline getOptionValue
TkDodo Mar 12, 2026
6cc7fe3
fix: custom value comparison for select
TkDodo Mar 12, 2026
4f44aa3
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 12, 2026
fde6d27
ref: fix imports
TkDodo Mar 12, 2026
a34aa5b
fix: don't send null or undefined into isValueEqual
TkDodo Mar 13, 2026
00a5310
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 13, 2026
a2ebcef
fix(select): prevent unmatched object values from crashing react-sele…
TkDodo Mar 13, 2026
b592426
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 13, 2026
5f3e107
fix(select): unmatched object fallback
TkDodo Mar 13, 2026
6e84699
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 16, 2026
1484478
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 16, 2026
db0c89c
ref: AutoSaveField becomes AutoSaveForm
TkDodo Mar 16, 2026
a274f93
fix: issueSeerBadge Quick Fix positioning
TkDodo Mar 16, 2026
2af0bdf
Revert "fix: issueSeerBadge Quick Fix positioning"
TkDodo Mar 16, 2026
e5d67ed
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 18, 2026
b6e8e77
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-exte…
TkDodo Mar 18, 2026
1ffd4c4
fix: error handling
TkDodo Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions static/app/components/core/form/field/selectField.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,51 @@ describe('SelectField', () => {
});
});

it('does not pass unmatched object values to react-select callbacks like getOptionValue', () => {
// When the current value is an object that doesn't match any option (e.g.
// options haven't loaded yet), Select should pass null to react-select —
// not the raw object. If the raw object were forwarded, react-select would
// treat it as an option and call getOptionValue on it. A custom
// getOptionValue that accesses option-specific properties (like `.value.id`)
// would crash because the raw form value has a different shape.
const OBJECT_OPTIONS: Array<SelectValue<{id: string; name: string}>> = [
{value: {id: '1', name: 'Apple'}, label: 'Apple'},
{value: {id: '2', name: 'Banana'}, label: 'Banana'},
];

function GetOptionValueForm() {
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {fruit: {id: '99', name: 'Mango'} as {id: string; name: string}},
});

return (
<form.AppForm form={form}>
<form.AppField name="fruit">
{field => (
<field.Select
value={field.state.value}
onChange={field.handleChange}
options={OBJECT_OPTIONS}
isValueEqual={(a, b) => a.id === b.id}
getOptionValue={opt => opt.value.id}
/>
)}
</form.AppField>
</form.AppForm>
);
}

// {id: '99', name: 'Mango'} doesn't match any option. Before the fix, the
// raw object was passed to react-select which called getOptionValue on it.
// getOptionValue expects a full option {value: {id, name}, label} but
// receives the raw {id, name}, so opt.value is undefined and .id throws.
render(<GetOptionValueForm />);

// Should render without crashing — no selected value is shown
expect(screen.getByRole('textbox')).toBeInTheDocument();
});

describe('SelectField disabled', () => {
it('is disabled when disabled prop is true', () => {
render(<TestForm label="Favorite Fruit" disabled />);
Expand Down
5 changes: 5 additions & 0 deletions static/app/components/core/form/field/selectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ type BaseSelectFieldProps<TValue, IsMulti extends boolean> = Omit<
> &
BaseFieldProps<HTMLInputElement> & {
options: ReadonlyArray<SelectValue<TValue>>;
/**
* custom value comparator function
* defaults to === comparison of the option values
*/
isValueEqual?: (optionA: TValue, optionB: TValue) => boolean;
};

// Helper type for non-array constraint
Expand Down
52 changes: 45 additions & 7 deletions static/app/components/core/form/scrapsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {FieldMeta} from '@sentry/scraps/form/field/meta';
import {FieldLayout} from '@sentry/scraps/form/layout';
import {FieldGroup} from '@sentry/scraps/form/layout/fieldGroup';

import RequestError from 'sentry/utils/requestError/requestError';

import {InputField} from './field/inputField';
import {NumberField} from './field/numberField';
import {PasswordField} from './field/passwordField';
Expand Down Expand Up @@ -127,22 +129,58 @@ type InferFormData<T> = T extends {state: {values: infer D}} ? D : never;
* Sets field errors on a form after submission (e.g., from backend validation).
* This provides a type-safe way to set errors on specific fields.
*
* Accepts either a `FieldErrors` object for manually constructed errors, or a
* `RequestError` to automatically extract field errors from `responseJSON`.
* When given a `RequestError`, only keys matching existing form fields are used.
* String values are used directly; array values use the first element.
*
* @example
* ```tsx
* const form = useScrapsForm({
* defaultValues: { firstName: '', lastName: '', address: { city: '' } },
* });
*
* // In onSubmit handler or after receiving backend errors:
* setFieldErrors(form, {
* // With manual field errors:
* setFieldErrors(formApi, {
* firstName: { message: 'This name is already taken' },
* 'address.city': { message: 'City not found' },
* });
*
* // With a RequestError (e.g., in an onSubmit handler):
* onSubmit: ({value, formApi}) => {
* return mutation.mutateAsync(value).catch((error: RequestError) => {
* setFieldErrors(formApi, error);
* });
* },
* ```
*/
export function setFieldErrors<
TForm extends {setErrorMap: (...args: any[]) => unknown; state: {values: unknown}},
>(formApi: TForm, errors: FieldErrors<InferFormData<TForm>>): void {
>(formApi: TForm, errors: FieldErrors<InferFormData<TForm>> | RequestError): void {
if (errors instanceof RequestError) {
const responseJSON = errors.responseJSON;
if (!responseJSON) {
return;
}
const formValues = formApi.state.values;
const fieldErrors: Record<string, {message: string}> = {};

for (const key of Object.keys(responseJSON)) {
if (typeof formValues === 'object' && formValues !== null && key in formValues) {
const value = responseJSON[key];
if (typeof value === 'string') {
fieldErrors[key] = {message: value};
} else if (Array.isArray(value) && value.length > 0) {
fieldErrors[key] = {
message: typeof value[0] === 'string' ? value[0] : String(value[0]),
};
}
}
}
Comment on lines +164 to +175
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have a isJSONResponseError or some type of helper that could assert on the type or perform the extraction for us?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

depends on if there’s a known structure that the backend sends. I know sometimes, it sends detail, which can be a string or an object too:

const detail = (queryError as RequestError | undefined)?.responseJSON?.detail;
if (typeof detail === 'string') {
return detail;
}
if (detail?.message) {
return detail.message;
}

A good solution would be to make sure all backend endpoints return field errors in a standardized format, then we can add our types accordingly directly to RequestError


if (Object.keys(fieldErrors).length > 0) {
formApi.setErrorMap({
onSubmit: {fields: fieldErrors},
});
}
return;
}
formApi.setErrorMap({
onSubmit: {
fields: errors,
Expand Down
119 changes: 119 additions & 0 deletions static/app/components/core/form/setFieldErrors.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import {setFieldErrors} from '@sentry/scraps/form';

import RequestError from 'sentry/utils/requestError/requestError';

function createMockFormApi(values: Record<string, unknown>) {
return {
setErrorMap: jest.fn(),
state: {values},
};
}

function createRequestError(responseJSON?: Record<string, unknown>): RequestError {
const error = new RequestError('POST', '/test/', new Error('test'));
if (responseJSON) {
error.responseJSON = responseJSON;
}
return error;
}

describe('setFieldErrors', () => {
describe('with FieldErrors object', () => {
it('sets field errors directly via setErrorMap', () => {
const formApi = createMockFormApi({name: '', email: ''});
const errors = {name: {message: 'Name is required'}};

setFieldErrors(formApi, errors);

expect(formApi.setErrorMap).toHaveBeenCalledWith({
onSubmit: {fields: errors},
});
});
});

describe('with RequestError', () => {
it('handles string values', () => {
const formApi = createMockFormApi({fieldName: ''});
const error = createRequestError({fieldName: 'error message'});

setFieldErrors(formApi, error);

expect(formApi.setErrorMap).toHaveBeenCalledWith({
onSubmit: {fields: {fieldName: {message: 'error message'}}},
});
});

it('handles array values by taking the first element', () => {
const formApi = createMockFormApi({fieldName: ''});
const error = createRequestError({fieldName: ['first error', 'second']});

setFieldErrors(formApi, error);

expect(formApi.setErrorMap).toHaveBeenCalledWith({
onSubmit: {fields: {fieldName: {message: 'first error'}}},
});
});

it('only sets errors for known fields', () => {
const formApi = createMockFormApi({name: '', email: ''});
const error = createRequestError({
name: ['err'],
unknown_field: ['err'],
});

setFieldErrors(formApi, error);

expect(formApi.setErrorMap).toHaveBeenCalledWith({
onSubmit: {fields: {name: {message: 'err'}}},
});
});

it('does not set errors when responseJSON is missing', () => {
const formApi = createMockFormApi({name: ''});
const error = createRequestError();

setFieldErrors(formApi, error);

expect(formApi.setErrorMap).not.toHaveBeenCalled();
});

it('does not set errors when responseJSON is empty', () => {
const formApi = createMockFormApi({name: ''});
const error = createRequestError({});

setFieldErrors(formApi, error);

expect(formApi.setErrorMap).not.toHaveBeenCalled();
});

it('handles mixed field types (string and array)', () => {
const formApi = createMockFormApi({name: '', email: ''});
const error = createRequestError({
name: 'string error',
email: ['array error'],
});

setFieldErrors(formApi, error);

expect(formApi.setErrorMap).toHaveBeenCalledWith({
onSubmit: {
fields: {
name: {message: 'string error'},
email: {message: 'array error'},
},
},
});
});

it('stringifies non-string array items', () => {
const formApi = createMockFormApi({field: ''});
const error = createRequestError({field: [123]});

setFieldErrors(formApi, error);

expect(formApi.setErrorMap).toHaveBeenCalledWith({
onSubmit: {fields: {field: {message: '123'}}},
});
});
});
});
34 changes: 31 additions & 3 deletions static/app/components/core/select/select.tsx
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changes in this file are so that we can support object values in our select component. We need that because when the user selects a team, we need more than one value from that team (we need slug and id).

react-select actually supports this easily because it wants the value to be a full option (incl. label etc), and it also returns that in onChange. However we have decided to make our select abstraction work differently - it only takes the value from inside the option and we then try to match it back against options to pass it forward to react-select.

this complicates things a lot, because objects aren’t === comparable, and if we give react-select a raw object it treats it as option but it might not have the correct structure.

I really hope these changes don’t break any other parts but I don’t think selects with object values are used a lot. Suffice to say I 🤬 select 😂

Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {IconChevron, IconClose} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {Choices, SelectValue} from 'sentry/types/core';
import {defined} from 'sentry/utils';
import {convertFromSelect2Choices} from 'sentry/utils/convertFromSelect2Choices';
import {PanelProvider} from 'sentry/utils/panelProvider';
import type {FormSize, Theme} from 'sentry/utils/theme';
Expand Down Expand Up @@ -456,6 +457,15 @@ export type ControlProps<OptionType extends OptionTypeBase = GeneralSelectValue>
* Whether this selector is being rendered inside a modal. If true, the menu will have a higher z-index.
*/
isInsideModal?: boolean;
/**
* custom value comparator function
* defaults to === comparison of the option values
*/
isValueEqual?: (
optionA: OptionType['value'],
optionB: OptionType['value']
) => boolean;

/**
* Maximum width of the menu component. Menu item labels that overflow the
* menu's boundaries will automatically be truncated.
Expand Down Expand Up @@ -539,7 +549,14 @@ export function Select<OptionType extends GeneralSelectValue = GeneralSelectValu
options;

// It's possible that `choicesOrOptions` does not exist (e.g. in the case of AsyncSelect)
let mappedValue = value;
// When isValueEqual is provided, the value is a raw domain object (e.g.
// {id, name}) that react-select can't handle directly — it would call
// callbacks like getOptionValue on it expecting a full option shape. In that
// case, fall back to null instead of passing the raw object through.
// Otherwise the value is a primitive or option-shaped object that
// react-select can handle directly.
const noMatchFallback = props.isValueEqual ? (props.multiple ? [] : null) : value;
let mappedValue = noMatchFallback;

if (choicesOrOptions) {
/**
Expand All @@ -554,10 +571,21 @@ export function Select<OptionType extends GeneralSelectValue = GeneralSelectValu
} else {
flatOptions = choicesOrOptions.flatMap((option: any) => option);
}

const compare = (
a: OptionType['value'] | null | undefined,
b: OptionType['value'] | null | undefined
) => {
if (props.isValueEqual && defined(a) && defined(b)) {
return props.isValueEqual(a, b);
}
return a === b;
};

mappedValue =
props.multiple && Array.isArray(value)
? value.map(val => flatOptions.find(option => option.value === val))
: flatOptions.find(opt => opt.value === value) || value;
? value.map(val => flatOptions.find(option => compare(option.value, val)))
: flatOptions.find(opt => compare(opt.value, value)) || noMatchFallback;
}

// Override the default style with in-field labels if they are provided
Expand Down
7 changes: 0 additions & 7 deletions static/app/utils/integrationUtil.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import * as qs from 'query-string';

import type {Result} from '@sentry/scraps/select';

import {
IconAsana,
IconBitbucket,
Expand Down Expand Up @@ -323,11 +321,6 @@ export const getExternalActorEndpointDetails = (
};
};

export const sentryNameToOption = ({id, name}: any): Result => ({
value: id,
label: name,
});

export function getIntegrationStatus(integration: Integration) {
// there are multiple status fields for an integration we consider
const statusList = [integration.organizationIntegrationStatus, integration.status];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,23 +117,9 @@ export function NewProviderForm({
defaultValues,
validators: {onDynamic: schema},
onSubmit: ({value, formApi}) => {
return mutation.mutateAsync(schema.parse(value)).catch((error: RequestError) => {
const responseJSON = error.responseJSON;
if (responseJSON?.secret || responseJSON?.provider) {
const extractMessage = (val: unknown): string => {
if (Array.isArray(val)) {
return typeof val[0] === 'string' ? val[0] : JSON.stringify(val[0]);
}
return typeof val === 'string' ? val : JSON.stringify(val);
};
const errors: Record<string, {message: string}> = {};
if (responseJSON.secret) {
errors.secret = {message: extractMessage(responseJSON.secret)};
}
if (responseJSON.provider) {
errors.provider = {message: extractMessage(responseJSON.provider)};
}
setFieldErrors(formApi, errors);
return mutation.mutateAsync(schema.parse(value)).catch(error => {
if (error instanceof RequestError) {
setFieldErrors(formApi, error);
Comment on lines +120 to +122
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌 nice refactor!

}
});
},
Expand Down
Loading
Loading