Skip to content
Merged
Show file tree
Hide file tree
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 Mar 12, 2026
f2cca95
improve types
TkDodo Mar 12, 2026
d56947b
choiceMapperAdapter.tsx
TkDodo Mar 13, 2026
80826b9
fix: disabled
TkDodo Mar 13, 2026
6f84847
move logic to choiceMapperAdapter
TkDodo Mar 13, 2026
3d3e598
fix: spacing in row layout
TkDodo Mar 13, 2026
335ee8e
fix: layout
TkDodo Mar 13, 2026
8c3460a
ref: type errors
TkDodo Mar 13, 2026
7b1f887
ref: better layouting
TkDodo Mar 13, 2026
44ee515
fix: use layout components
TkDodo Mar 13, 2026
b4ccc00
fix: better types
TkDodo Mar 13, 2026
6520337
ref: separate tests
TkDodo Mar 13, 2026
f1a847f
feat: project mapper
TkDodo Mar 13, 2026
87bc273
fix: better layout
TkDodo Mar 13, 2026
b3fee83
fix: icons
TkDodo Mar 13, 2026
2658495
ref: rename
TkDodo Mar 13, 2026
5bf9168
as const to the rescue
TkDodo Mar 13, 2026
c6716f1
improve defaultValue types
TkDodo Mar 13, 2026
da5151b
feat: table adapter
TkDodo Mar 13, 2026
630bf7d
alert banner
TkDodo Mar 13, 2026
c73857d
alert banner
TkDodo Mar 13, 2026
cfe9b29
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo Mar 13, 2026
e8f29de
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo Mar 16, 2026
6b524f2
fix import
TkDodo Mar 16, 2026
fa84787
tests: remove renderField abstraction as it doesn't go well with gene…
TkDodo Mar 16, 2026
133b5b0
fix: remove more test abstractions
TkDodo Mar 16, 2026
7e189b5
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo Mar 16, 2026
bb4c27b
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo Mar 16, 2026
312ca60
ref: AutoSaveField becomes AutoSaveForm
TkDodo Mar 16, 2026
4ac18ba
ref: types
TkDodo Mar 17, 2026
d2808af
ref: make switch statements exhaustive
TkDodo Mar 17, 2026
b59573b
fix: make sure mutations stay pending while refetching
TkDodo Mar 17, 2026
cf60276
ref: streamline schema creation
TkDodo Mar 17, 2026
ad28bc5
ref: streamline initialValue
TkDodo Mar 17, 2026
a313e7b
fix: allow enter submit
TkDodo Mar 17, 2026
9dec86c
ref: move empty row creation into event handler
TkDodo Mar 17, 2026
c5618f8
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo Mar 17, 2026
61d2475
fix: select schema
TkDodo Mar 17, 2026
4e667e2
fix: zod schemas
TkDodo Mar 17, 2026
b0c8683
fix: label display
TkDodo Mar 17, 2026
28fc03c
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo Mar 17, 2026
95d31d7
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo Mar 18, 2026
e7d2d07
fix: url and email types
TkDodo Mar 18, 2026
e2cb450
fix: height does not exist on select
TkDodo Mar 18, 2026
10d4eac
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo Mar 18, 2026
b1fafea
ref: comment
TkDodo Mar 18, 2026
ae1f7f5
Merge branch 'master' into tkdodo/ref/de-948-migrate-integration-org-…
TkDodo Mar 18, 2026
2f884d2
ref(choiceMapper): add disabled prop to ChoiceMapperDropdown and rela…
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

Large diffs are not rendered by default.

325 changes: 325 additions & 0 deletions static/app/components/backendJsonFormAdapter/choiceMapperAdapter.tsx
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, {
Copy link
Collaborator Author

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/0 baseURL that gets pre-pended to all apiOptions and useApiQuery. I don’t see any other option right now except creating our own api client and then using the deprecated requestPromise...

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);
};

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}));
}
Loading
Loading