import {Component, Fragment} from 'react'; import styled from '@emotion/styled'; import Button from 'sentry/components/button'; import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete'; import {Item} from 'sentry/components/dropdownAutoComplete/types'; import DropdownButton from 'sentry/components/dropdownButton'; import InputField, {InputFieldProps} from 'sentry/components/forms/inputField'; import SelectControl, {ControlProps} from 'sentry/components/forms/selectControl'; import {IconAdd, IconDelete} from 'sentry/icons'; import {t} from 'sentry/locale'; import space from 'sentry/styles/space'; import {defined, objectIsEmpty} from 'sentry/utils'; interface DefaultProps { /** * Text used for the 'add row' button. */ addButtonText: NonNullable; /** * Automatically save even if fields are empty */ allowEmpty: boolean; /** * If using mappedSelectors to specifically map different choice selectors * per item specify this as true. */ perItemMapping: boolean; } const defaultProps: DefaultProps = { addButtonText: t('Add Item'), perItemMapping: false, allowEmpty: false, }; type MappedSelectors = Record>; export interface ChoiceMapperProps extends DefaultProps { /** * Props forwarded to the add mapping dropdown. */ addDropdown: React.ComponentProps; /** * A list of column labels (headers) for the multichoice table. This should * have the same mapping keys as the mappedSelectors prop. */ columnLabels: Record; /** * Since we're saving an object, there isn't a great way to render the * change within the toast. Just turn off displaying the from/to portion of * the message. */ formatMessageValue: boolean; /** * mappedSelectors controls how the Select control should render for each * column. This can be generalised so that each column renders the same set * of choices for each mapped item by providing an object with column * label keys mapping to the select descriptor, OR you may specify the set * of select descriptors *specific* to a mapped item, where the item value * maps to the object of column label keys to select descriptor. * * Example - All selects are the same per column: * * { * 'column_key1: {...select1}, * 'column_key2: {...select2}, * } * * Example - Selects differ for each of the items available: * * { * 'my_object_value': {'column_key1': {...select1}, 'column_key2': {...select2}}, * 'other_object_val': {'column_key1': {...select3}, 'column_key2': {...select4}}, * } */ mappedSelectors: MappedSelectors; onChange: InputFieldProps['onChange']; // TODO(ts) tighten this up. value: Record; /** * Field controls get a boolean. */ disabled?: boolean; /** * The label to show above the row name selected from the dropdown. */ mappedColumnLabel?: React.ReactNode; // TODO(ts) This isn't aligned with InputField but that's what the runtime code had. onBlur?: () => void; } export interface ChoiceMapperFieldProps extends ChoiceMapperProps, Omit< InputFieldProps, 'onBlur' | 'onChange' | 'value' | 'formatMessageValue' | 'disabled' > {} export default class ChoiceMapper extends Component { static defaultProps = defaultProps; hasValue = (value: InputFieldProps['value']) => defined(value) && !objectIsEmpty(value); renderField = (props: ChoiceMapperFieldProps) => { const { onChange, onBlur, addButtonText, addDropdown, mappedColumnLabel, columnLabels, mappedSelectors, perItemMapping, disabled, allowEmpty, } = props; const mappedKeys = Object.keys(columnLabels); const emptyValue = mappedKeys.reduce((a, v) => ({...a, [v]: null}), {}); const valueIsEmpty = this.hasValue(props.value); const value = valueIsEmpty ? props.value : {}; const saveChanges = (nextValue: ChoiceMapperFieldProps['value']) => { onChange?.(nextValue, {}); const validValues = !Object.values(nextValue) .map(o => Object.values(o).find(v => v === null)) .includes(null); if (allowEmpty || validValues) { onBlur?.(); } }; const addRow = (data: Item) => { saveChanges({...value, [data.value]: emptyValue}); }; const removeRow = (itemKey: string) => { // eslint-disable-next-line no-unused-vars saveChanges( Object.fromEntries(Object.entries(value).filter(([key, _]) => key !== itemKey)) ); }; const setValue = ( itemKey: string, fieldKey: string, fieldValue: string | number | null ) => { saveChanges({...value, [itemKey]: {...value[itemKey], [fieldKey]: fieldValue}}); }; // Remove already added values from the items list const selectableValues = addDropdown.items?.filter(i => !value.hasOwnProperty(i.value)) ?? []; const valueMap = addDropdown.items?.reduce((map, item) => { map[item.value] = item.label; return map; }, {}) ?? {}; const dropdown = ( {({isOpen}) => ( } isOpen={isOpen} size="xs" disabled={disabled} > {addButtonText} )} ); // The field will be set to inline when there is no value set for the // field, just show the dropdown. if (!valueIsEmpty) { return
{dropdown}
; } return (
{mappedColumnLabel} {mappedKeys.map((fieldKey, i) => ( {columnLabels[fieldKey]} {i === mappedKeys.length - 1 && dropdown} ))}
{Object.keys(value).map(itemKey => ( {valueMap[itemKey]} {mappedKeys.map((fieldKey, i) => ( setValue(itemKey, fieldKey, v ? v.value : null)} value={value[itemKey][fieldKey]} /> {i === mappedKeys.length - 1 && (