-
-
Notifications
You must be signed in to change notification settings - Fork 4.6k
ref(settings): migrate integration org settings #110666
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
TkDodo
merged 48 commits into
master
from
tkdodo/ref/de-948-migrate-integration-org-settings
Mar 18, 2026
Merged
Changes from all commits
Commits
Show all changes
48 commits
Select commit
Hold shift + click to select a range
32859b3
ref: adapter for json config sent from the backend
TkDodo f2cca95
improve types
TkDodo d56947b
choiceMapperAdapter.tsx
TkDodo 80826b9
fix: disabled
TkDodo 6f84847
move logic to choiceMapperAdapter
TkDodo 3d3e598
fix: spacing in row layout
TkDodo 335ee8e
fix: layout
TkDodo 8c3460a
ref: type errors
TkDodo 7b1f887
ref: better layouting
TkDodo 44ee515
fix: use layout components
TkDodo b4ccc00
fix: better types
TkDodo 6520337
ref: separate tests
TkDodo f1a847f
feat: project mapper
TkDodo 87bc273
fix: better layout
TkDodo b3fee83
fix: icons
TkDodo 2658495
ref: rename
TkDodo 5bf9168
as const to the rescue
TkDodo c6716f1
improve defaultValue types
TkDodo da5151b
feat: table adapter
TkDodo 630bf7d
alert banner
TkDodo c73857d
alert banner
TkDodo cfe9b29
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo e8f29de
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo 6b524f2
fix import
TkDodo fa84787
tests: remove renderField abstraction as it doesn't go well with gene…
TkDodo 133b5b0
fix: remove more test abstractions
TkDodo 7e189b5
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo bb4c27b
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo 312ca60
ref: AutoSaveField becomes AutoSaveForm
TkDodo 4ac18ba
ref: types
TkDodo d2808af
ref: make switch statements exhaustive
TkDodo b59573b
fix: make sure mutations stay pending while refetching
TkDodo cf60276
ref: streamline schema creation
TkDodo ad28bc5
ref: streamline initialValue
TkDodo a313e7b
fix: allow enter submit
TkDodo 9dec86c
ref: move empty row creation into event handler
TkDodo c5618f8
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo 61d2475
fix: select schema
TkDodo 4e667e2
fix: zod schemas
TkDodo b0c8683
fix: label display
TkDodo 28fc03c
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo 95d31d7
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo e7d2d07
fix: url and email types
TkDodo e2cb450
fix: height does not exist on select
TkDodo 10d4eac
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo b1fafea
ref: comment
TkDodo ae1f7f5
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo 2f884d2
ref(choiceMapper): add disabled prop to ChoiceMapperDropdown and rela…
TkDodo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
511 changes: 511 additions & 0 deletions
511
static/app/components/backendJsonFormAdapter/choiceMapperAdapter.spec.tsx
Large diffs are not rendered by default.
Oops, something went wrong.
325 changes: 325 additions & 0 deletions
325
static/app/components/backendJsonFormAdapter/choiceMapperAdapter.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,325 @@ | ||
| import {useState, type ReactNode} from 'react'; | ||
| import {useQuery} from '@tanstack/react-query'; | ||
| import type {DistributedPick} from 'type-fest'; | ||
|
|
||
| import {Button} from '@sentry/scraps/button'; | ||
| import { | ||
| CompactSelect, | ||
| type SelectOption, | ||
| type SelectProps, | ||
| } from '@sentry/scraps/compactSelect'; | ||
| import {Flex, Stack} from '@sentry/scraps/layout'; | ||
| import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; | ||
| import {Select} from '@sentry/scraps/select'; | ||
| import {Text} from '@sentry/scraps/text'; | ||
|
|
||
| import {Client} from 'sentry/api'; | ||
| import {IconAdd, IconDelete} from 'sentry/icons'; | ||
| import {t} from 'sentry/locale'; | ||
| import {useApi} from 'sentry/utils/useApi'; | ||
| import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; | ||
|
|
||
| import type {JsonFormAdapterFieldConfig} from './types'; | ||
|
|
||
| type ChoiceMapperConfig = Extract<JsonFormAdapterFieldConfig, {type: 'choice_mapper'}>; | ||
|
|
||
| interface ChoiceMapperDropdownProps { | ||
| config: ChoiceMapperConfig; | ||
| onChange: (value: Record<string, Record<string, unknown>>) => void; | ||
| onLabelAdd: (value: string, label: ReactNode) => void; | ||
| value: Record<string, Record<string, unknown>>; | ||
| disabled?: boolean; | ||
| indicator?: React.ReactNode; | ||
| } | ||
|
|
||
| interface ChoiceMapperTableProps { | ||
| config: ChoiceMapperConfig; | ||
| labels: Record<string, ReactNode>; | ||
| onSave: (value: Record<string, Record<string, unknown>>) => void; | ||
| onUpdate: (value: Record<string, Record<string, unknown>>) => void; | ||
| value: Record<string, Record<string, unknown>>; | ||
| disabled?: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Async search dropdown that fetches options from a URL as the user types. | ||
| */ | ||
| function AsyncSearchCompactSelect({ | ||
| url, | ||
| searchField, | ||
| defaultOptions, | ||
| noResultsMessage, | ||
| disabled, | ||
| onChange, | ||
| ...triggerProps | ||
| }: { | ||
| defaultOptions: Array<SelectOption<string>>; | ||
| onChange: (option: SelectOption<string>) => void; | ||
| url: string; | ||
| disabled?: boolean; | ||
| noResultsMessage?: string; | ||
| searchField?: string; | ||
| } & DistributedPick<SelectProps<string>, 'trigger'>) { | ||
| const [search, setSearch] = useState(''); | ||
| const debouncedSearch = useDebouncedValue(search, 200); | ||
| const [apiClient] = useState(() => new Client({baseUrl: ''})); | ||
| const api = useApi({api: apiClient}); | ||
|
|
||
| const {data: options = defaultOptions, isFetching} = useQuery({ | ||
| queryKey: [url, {field: searchField, query: debouncedSearch}], | ||
| queryFn: (): Promise<Array<SelectOption<string>>> => | ||
| api.requestPromise(url, { | ||
| query: {field: searchField, query: debouncedSearch}, | ||
| }), | ||
| enabled: !!debouncedSearch, | ||
| staleTime: 30_000, | ||
| placeholderData: previousData => (debouncedSearch ? previousData : undefined), | ||
| select: data => data.map(item => ({value: item.value, label: item.label})), | ||
| }); | ||
|
|
||
| return ( | ||
| <CompactSelect | ||
| {...triggerProps} | ||
| value={undefined} | ||
| search={{filter: false, onChange: setSearch}} | ||
| clearable={false} | ||
| disabled={disabled} | ||
| options={options} | ||
| onChange={option => { | ||
| setSearch(''); | ||
| onChange(option); | ||
| }} | ||
| onOpenChange={(isOpen: boolean) => { | ||
| if (!isOpen) { | ||
| setSearch(''); | ||
| } | ||
| }} | ||
| loading={isFetching} | ||
| emptyMessage={ | ||
| isFetching | ||
| ? t('Searching\u2026') | ||
| : debouncedSearch | ||
| ? (noResultsMessage ?? t('No results found')) | ||
| : t('Type to search') | ||
| } | ||
| size="xs" | ||
| menuWidth={250} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Renders just the "Add" dropdown button. | ||
| * Placed inside Layout.Row alongside the label. | ||
| */ | ||
| export function ChoiceMapperDropdown({ | ||
| config, | ||
| value, | ||
| onChange, | ||
| onLabelAdd, | ||
| disabled, | ||
| indicator, | ||
| }: ChoiceMapperDropdownProps) { | ||
| const {columnLabels = {}} = config; | ||
|
|
||
| const asyncUrl = config.addDropdown?.url; | ||
| const selectableValues = | ||
| config.addDropdown?.items?.filter(i => !value.hasOwnProperty(i.value)) ?? []; | ||
|
|
||
| const addRow = (item: SelectOption<string>) => { | ||
| const emptyValue = Object.keys(columnLabels).reduce<Record<string, null>>( | ||
| (acc, key) => { | ||
| acc[key] = null; | ||
| return acc; | ||
| }, | ||
| {} | ||
| ); | ||
|
|
||
| if (asyncUrl) { | ||
| // using item.value as label because it's what we display for saved values too | ||
| onLabelAdd(item.value, item.value); | ||
| } | ||
| onChange({...value, [item.value]: emptyValue}); | ||
| }; | ||
|
|
||
| if (asyncUrl) { | ||
| return ( | ||
| <Flex align="center" gap="sm"> | ||
| <AsyncSearchCompactSelect | ||
| url={asyncUrl} | ||
| searchField={config.addDropdown?.searchField} | ||
| defaultOptions={selectableValues.map(i => ({value: i.value, label: i.label}))} | ||
| noResultsMessage={config.addDropdown?.noResultsMessage ?? t('No results found')} | ||
| disabled={disabled} | ||
| onChange={addRow} | ||
| trigger={triggerProps => ( | ||
| <OverlayTrigger.Button {...triggerProps}> | ||
| <Flex gap="xs"> | ||
| <IconAdd /> {config.addButtonText ?? t('Add Item')} | ||
| </Flex> | ||
| </OverlayTrigger.Button> | ||
| )} | ||
| /> | ||
| {indicator} | ||
| </Flex> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Flex align="center" gap="sm"> | ||
| <CompactSelect | ||
| value={undefined} | ||
| emptyMessage={ | ||
| selectableValues.length === 0 | ||
| ? config.addDropdown?.emptyMessage | ||
| : config.addDropdown?.noResultsMessage | ||
| } | ||
| size="xs" | ||
| search | ||
| disabled={disabled} | ||
| options={selectableValues.map(i => ({value: i.value, label: i.label}))} | ||
| menuWidth={250} | ||
| onChange={addRow} | ||
| trigger={triggerProps => ( | ||
| <OverlayTrigger.Button {...triggerProps}> | ||
| <Flex gap="xs"> | ||
| <IconAdd /> {config.addButtonText ?? t('Add Item')} | ||
| </Flex> | ||
| </OverlayTrigger.Button> | ||
| )} | ||
| /> | ||
| {indicator} | ||
| </Flex> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Renders the mapping table rows (header + data rows). | ||
| * Placed below the Layout.Row. | ||
| */ | ||
| export function ChoiceMapperTable({ | ||
| config, | ||
| labels, | ||
| value, | ||
| onUpdate, | ||
| onSave, | ||
| disabled, | ||
| }: ChoiceMapperTableProps) { | ||
| const {addDropdown, columnLabels = {}, mappedColumnLabel} = config; | ||
|
|
||
| const mappedKeys = Object.keys(columnLabels); | ||
|
|
||
| const getSelector = (itemKey: string, fieldKey: string) => { | ||
| if (config.perItemMapping) { | ||
| return config.mappedSelectors?.[itemKey]?.[fieldKey]; | ||
| } | ||
| return config.mappedSelectors?.[fieldKey]; | ||
| }; | ||
|
|
||
| const labelMap = | ||
| addDropdown?.items?.reduce( | ||
| (map, item) => { | ||
| map[item.value] = item.label; | ||
| return map; | ||
| }, | ||
| {...labels} | ||
| ) ?? {}; | ||
|
|
||
| const allColumnsFilled = (val: Record<string, Record<string, unknown>>) => | ||
| Object.values(val).every(row => | ||
| mappedKeys.every(key => row[key] !== null && row[key] !== undefined) | ||
| ); | ||
|
|
||
| const updateAndSaveIfComplete = (newValue: Record<string, Record<string, unknown>>) => { | ||
| onUpdate(newValue); | ||
| if (allColumnsFilled(newValue)) { | ||
| onSave(newValue); | ||
| } | ||
| }; | ||
|
|
||
| const removeRow = (itemKey: string) => { | ||
| const newValue = Object.fromEntries( | ||
| Object.entries(value).filter(([key]) => key !== itemKey) | ||
| ); | ||
| updateAndSaveIfComplete(newValue); | ||
| }; | ||
TkDodo marked this conversation as resolved.
Show resolved
Hide resolved
sentry[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const setValue = ( | ||
| itemKey: string, | ||
| fieldKey: string, | ||
| cellValue: string | number | null | ||
| ) => { | ||
| const newValue = {...value, [itemKey]: {...value[itemKey], [fieldKey]: cellValue}}; | ||
| updateAndSaveIfComplete(newValue); | ||
| }; | ||
|
|
||
| const hasValues = Object.keys(value).length > 0; | ||
|
|
||
| if (!hasValues) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <Stack gap="lg"> | ||
| <Flex align="center" gap="md"> | ||
| <Flex flex="0 0 200px"> | ||
| <Text variant="muted" size="xs" uppercase> | ||
| {mappedColumnLabel} | ||
| </Text> | ||
| </Flex> | ||
| {mappedKeys.map(fieldKey => ( | ||
| <Flex justify="between" align="center" flex="1 0 0" key={fieldKey}> | ||
| <Text variant="muted" size="xs" uppercase> | ||
| {columnLabels[fieldKey]} | ||
| </Text> | ||
| </Flex> | ||
| ))} | ||
| </Flex> | ||
| {Object.keys(value).map(itemKey => ( | ||
| <Flex align="center" gap="md" key={itemKey}> | ||
| <Flex flex="0 0 200px">{labelMap[itemKey]}</Flex> | ||
| {mappedKeys.map((fieldKey, i) => ( | ||
| <Flex align="center" flex="1 0 0" gap="md" key={fieldKey}> | ||
| <Flex flex="1" direction="column"> | ||
| <Select | ||
| {...getSelector(itemKey, fieldKey)} | ||
| options={transformMappedChoices(getSelector(itemKey, fieldKey))} | ||
| disabled={disabled} | ||
| onChange={(v: {value: string | number | null} | null) => | ||
| setValue(itemKey, fieldKey, v ? v.value : null) | ||
| } | ||
| value={value[itemKey]?.[fieldKey] as string | null} | ||
| /> | ||
| </Flex> | ||
| {i === mappedKeys.length - 1 && ( | ||
| <Button | ||
| icon={<IconDelete />} | ||
| size="sm" | ||
| disabled={disabled} | ||
| onClick={() => removeRow(itemKey)} | ||
| aria-label={t('Delete')} | ||
| /> | ||
| )} | ||
| </Flex> | ||
| ))} | ||
| </Flex> | ||
| ))} | ||
| </Stack> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Transform choice tuples from the backend config into Select options. | ||
| */ | ||
| function transformMappedChoices( | ||
| selector?: {choices?: Array<[string, string]>; placeholder?: string} | unknown | ||
| ): Array<{label: string; value: string}> { | ||
| if (!selector || typeof selector !== 'object') { | ||
| return []; | ||
| } | ||
| const choices = (selector as {choices?: Array<[string, string]>}).choices; | ||
| if (!Array.isArray(choices)) { | ||
| return []; | ||
| } | ||
| return choices.map(([val, label]) => ({value: val, label})); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
urls from the server are sent as absolute urls, no
/api/0baseURL that gets pre-pended to allapiOptionsanduseApiQuery. I don’t see any other option right now except creating our own api client and then using the deprecated requestPromise...