123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297 |
- 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<React.ReactNode>;
- /**
- * 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<string, Partial<ControlProps>>;
- export interface ChoiceMapperProps extends DefaultProps {
- /**
- * Props forwarded to the add mapping dropdown.
- */
- addDropdown: React.ComponentProps<typeof DropdownAutoComplete>;
- /**
- * A list of column labels (headers) for the multichoice table. This should
- * have the same mapping keys as the mappedSelectors prop.
- */
- columnLabels: Record<string, React.ReactNode>;
- /**
- * 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<string, any>;
- /**
- * 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<ChoiceMapperFieldProps> {
- 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 = (
- <DropdownAutoComplete
- {...addDropdown}
- alignMenu={valueIsEmpty ? 'right' : 'left'}
- items={selectableValues}
- onSelect={addRow}
- disabled={disabled}
- >
- {({isOpen}) => (
- <DropdownButton
- icon={<IconAdd size="xs" isCircled />}
- isOpen={isOpen}
- size="xs"
- disabled={disabled}
- >
- {addButtonText}
- </DropdownButton>
- )}
- </DropdownAutoComplete>
- );
- // The field will be set to inline when there is no value set for the
- // field, just show the dropdown.
- if (!valueIsEmpty) {
- return <div>{dropdown}</div>;
- }
- return (
- <Fragment>
- <Header>
- <LabelColumn>
- <HeadingItem>{mappedColumnLabel}</HeadingItem>
- </LabelColumn>
- {mappedKeys.map((fieldKey, i) => (
- <Heading key={fieldKey}>
- <HeadingItem>{columnLabels[fieldKey]}</HeadingItem>
- {i === mappedKeys.length - 1 && dropdown}
- </Heading>
- ))}
- </Header>
- {Object.keys(value).map(itemKey => (
- <Row key={itemKey}>
- <LabelColumn>{valueMap[itemKey]}</LabelColumn>
- {mappedKeys.map((fieldKey, i) => (
- <Column key={fieldKey}>
- <Control>
- <SelectControl
- {...(perItemMapping
- ? mappedSelectors[itemKey][fieldKey]
- : mappedSelectors[fieldKey])}
- height={30}
- disabled={disabled}
- onChange={v => setValue(itemKey, fieldKey, v ? v.value : null)}
- value={value[itemKey][fieldKey]}
- />
- </Control>
- {i === mappedKeys.length - 1 && (
- <Actions>
- <Button
- icon={<IconDelete />}
- size="sm"
- disabled={disabled}
- onClick={() => removeRow(itemKey)}
- aria-label={t('Delete')}
- />
- </Actions>
- )}
- </Column>
- ))}
- </Row>
- ))}
- </Fragment>
- );
- };
- render() {
- return (
- <InputField
- {...this.props}
- inline={({model}) => !this.hasValue(model.getValue(this.props.name))}
- field={this.renderField}
- />
- );
- }
- }
- const Header = styled('div')`
- display: flex;
- align-items: center;
- `;
- const Heading = styled('div')`
- display: flex;
- margin-left: ${space(1)};
- flex: 1 0 0;
- align-items: center;
- justify-content: space-between;
- `;
- const Row = styled('div')`
- display: flex;
- margin-top: ${space(1)};
- align-items: center;
- `;
- const Column = styled('div')`
- display: flex;
- margin-left: ${space(1)};
- align-items: center;
- flex: 1 0 0;
- `;
- const Control = styled('div')`
- flex: 1;
- `;
- const LabelColumn = styled('div')`
- flex: 0 0 200px;
- `;
- const HeadingItem = styled('div')`
- font-size: 0.8em;
- text-transform: uppercase;
- color: ${p => p.theme.subText};
- `;
- const Actions = styled('div')`
- margin-left: ${space(1)};
- `;
|