choiceMapperField.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import {Component, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import Button from 'sentry/components/button';
  4. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  5. import {Item} from 'sentry/components/dropdownAutoComplete/types';
  6. import DropdownButton from 'sentry/components/dropdownButton';
  7. import InputField, {InputFieldProps} from 'sentry/components/forms/inputField';
  8. import SelectControl, {ControlProps} from 'sentry/components/forms/selectControl';
  9. import {IconAdd, IconDelete} from 'sentry/icons';
  10. import {t} from 'sentry/locale';
  11. import space from 'sentry/styles/space';
  12. import {defined, objectIsEmpty} from 'sentry/utils';
  13. interface DefaultProps {
  14. /**
  15. * Text used for the 'add row' button.
  16. */
  17. addButtonText: NonNullable<React.ReactNode>;
  18. /**
  19. * Automatically save even if fields are empty
  20. */
  21. allowEmpty: boolean;
  22. /**
  23. * If using mappedSelectors to specifically map different choice selectors
  24. * per item specify this as true.
  25. */
  26. perItemMapping: boolean;
  27. }
  28. const defaultProps: DefaultProps = {
  29. addButtonText: t('Add Item'),
  30. perItemMapping: false,
  31. allowEmpty: false,
  32. };
  33. type MappedSelectors = Record<string, Partial<ControlProps>>;
  34. export interface ChoiceMapperProps extends DefaultProps {
  35. /**
  36. * Props forwarded to the add mapping dropdown.
  37. */
  38. addDropdown: React.ComponentProps<typeof DropdownAutoComplete>;
  39. /**
  40. * A list of column labels (headers) for the multichoice table. This should
  41. * have the same mapping keys as the mappedSelectors prop.
  42. */
  43. columnLabels: Record<string, React.ReactNode>;
  44. /**
  45. * Since we're saving an object, there isn't a great way to render the
  46. * change within the toast. Just turn off displaying the from/to portion of
  47. * the message.
  48. */
  49. formatMessageValue: boolean;
  50. /**
  51. * mappedSelectors controls how the Select control should render for each
  52. * column. This can be generalised so that each column renders the same set
  53. * of choices for each mapped item by providing an object with column
  54. * label keys mapping to the select descriptor, OR you may specify the set
  55. * of select descriptors *specific* to a mapped item, where the item value
  56. * maps to the object of column label keys to select descriptor.
  57. *
  58. * Example - All selects are the same per column:
  59. *
  60. * {
  61. * 'column_key1: {...select1},
  62. * 'column_key2: {...select2},
  63. * }
  64. *
  65. * Example - Selects differ for each of the items available:
  66. *
  67. * {
  68. * 'my_object_value': {'column_key1': {...select1}, 'column_key2': {...select2}},
  69. * 'other_object_val': {'column_key1': {...select3}, 'column_key2': {...select4}},
  70. * }
  71. */
  72. mappedSelectors: MappedSelectors;
  73. onChange: InputFieldProps['onChange'];
  74. // TODO(ts) tighten this up.
  75. value: Record<string, any>;
  76. /**
  77. * Field controls get a boolean.
  78. */
  79. disabled?: boolean;
  80. /**
  81. * The label to show above the row name selected from the dropdown.
  82. */
  83. mappedColumnLabel?: React.ReactNode;
  84. // TODO(ts) This isn't aligned with InputField but that's what the runtime code had.
  85. onBlur?: () => void;
  86. }
  87. export interface ChoiceMapperFieldProps
  88. extends ChoiceMapperProps,
  89. Omit<
  90. InputFieldProps,
  91. 'onBlur' | 'onChange' | 'value' | 'formatMessageValue' | 'disabled'
  92. > {}
  93. export default class ChoiceMapper extends Component<ChoiceMapperFieldProps> {
  94. static defaultProps = defaultProps;
  95. hasValue = (value: InputFieldProps['value']) => defined(value) && !objectIsEmpty(value);
  96. renderField = (props: ChoiceMapperFieldProps) => {
  97. const {
  98. onChange,
  99. onBlur,
  100. addButtonText,
  101. addDropdown,
  102. mappedColumnLabel,
  103. columnLabels,
  104. mappedSelectors,
  105. perItemMapping,
  106. disabled,
  107. allowEmpty,
  108. } = props;
  109. const mappedKeys = Object.keys(columnLabels);
  110. const emptyValue = mappedKeys.reduce((a, v) => ({...a, [v]: null}), {});
  111. const valueIsEmpty = this.hasValue(props.value);
  112. const value = valueIsEmpty ? props.value : {};
  113. const saveChanges = (nextValue: ChoiceMapperFieldProps['value']) => {
  114. onChange?.(nextValue, {});
  115. const validValues = !Object.values(nextValue)
  116. .map(o => Object.values(o).find(v => v === null))
  117. .includes(null);
  118. if (allowEmpty || validValues) {
  119. onBlur?.();
  120. }
  121. };
  122. const addRow = (data: Item) => {
  123. saveChanges({...value, [data.value]: emptyValue});
  124. };
  125. const removeRow = (itemKey: string) => {
  126. // eslint-disable-next-line no-unused-vars
  127. saveChanges(
  128. Object.fromEntries(Object.entries(value).filter(([key, _]) => key !== itemKey))
  129. );
  130. };
  131. const setValue = (
  132. itemKey: string,
  133. fieldKey: string,
  134. fieldValue: string | number | null
  135. ) => {
  136. saveChanges({...value, [itemKey]: {...value[itemKey], [fieldKey]: fieldValue}});
  137. };
  138. // Remove already added values from the items list
  139. const selectableValues =
  140. addDropdown.items?.filter(i => !value.hasOwnProperty(i.value)) ?? [];
  141. const valueMap =
  142. addDropdown.items?.reduce((map, item) => {
  143. map[item.value] = item.label;
  144. return map;
  145. }, {}) ?? {};
  146. const dropdown = (
  147. <DropdownAutoComplete
  148. {...addDropdown}
  149. alignMenu={valueIsEmpty ? 'right' : 'left'}
  150. items={selectableValues}
  151. onSelect={addRow}
  152. disabled={disabled}
  153. >
  154. {({isOpen}) => (
  155. <DropdownButton
  156. icon={<IconAdd size="xs" isCircled />}
  157. isOpen={isOpen}
  158. size="xs"
  159. disabled={disabled}
  160. >
  161. {addButtonText}
  162. </DropdownButton>
  163. )}
  164. </DropdownAutoComplete>
  165. );
  166. // The field will be set to inline when there is no value set for the
  167. // field, just show the dropdown.
  168. if (!valueIsEmpty) {
  169. return <div>{dropdown}</div>;
  170. }
  171. return (
  172. <Fragment>
  173. <Header>
  174. <LabelColumn>
  175. <HeadingItem>{mappedColumnLabel}</HeadingItem>
  176. </LabelColumn>
  177. {mappedKeys.map((fieldKey, i) => (
  178. <Heading key={fieldKey}>
  179. <HeadingItem>{columnLabels[fieldKey]}</HeadingItem>
  180. {i === mappedKeys.length - 1 && dropdown}
  181. </Heading>
  182. ))}
  183. </Header>
  184. {Object.keys(value).map(itemKey => (
  185. <Row key={itemKey}>
  186. <LabelColumn>{valueMap[itemKey]}</LabelColumn>
  187. {mappedKeys.map((fieldKey, i) => (
  188. <Column key={fieldKey}>
  189. <Control>
  190. <SelectControl
  191. {...(perItemMapping
  192. ? mappedSelectors[itemKey][fieldKey]
  193. : mappedSelectors[fieldKey])}
  194. height={30}
  195. disabled={disabled}
  196. onChange={v => setValue(itemKey, fieldKey, v ? v.value : null)}
  197. value={value[itemKey][fieldKey]}
  198. />
  199. </Control>
  200. {i === mappedKeys.length - 1 && (
  201. <Actions>
  202. <Button
  203. icon={<IconDelete />}
  204. size="sm"
  205. disabled={disabled}
  206. onClick={() => removeRow(itemKey)}
  207. aria-label={t('Delete')}
  208. />
  209. </Actions>
  210. )}
  211. </Column>
  212. ))}
  213. </Row>
  214. ))}
  215. </Fragment>
  216. );
  217. };
  218. render() {
  219. return (
  220. <InputField
  221. {...this.props}
  222. inline={({model}) => !this.hasValue(model.getValue(this.props.name))}
  223. field={this.renderField}
  224. />
  225. );
  226. }
  227. }
  228. const Header = styled('div')`
  229. display: flex;
  230. align-items: center;
  231. `;
  232. const Heading = styled('div')`
  233. display: flex;
  234. margin-left: ${space(1)};
  235. flex: 1 0 0;
  236. align-items: center;
  237. justify-content: space-between;
  238. `;
  239. const Row = styled('div')`
  240. display: flex;
  241. margin-top: ${space(1)};
  242. align-items: center;
  243. `;
  244. const Column = styled('div')`
  245. display: flex;
  246. margin-left: ${space(1)};
  247. align-items: center;
  248. flex: 1 0 0;
  249. `;
  250. const Control = styled('div')`
  251. flex: 1;
  252. `;
  253. const LabelColumn = styled('div')`
  254. flex: 0 0 200px;
  255. `;
  256. const HeadingItem = styled('div')`
  257. font-size: 0.8em;
  258. text-transform: uppercase;
  259. color: ${p => p.theme.subText};
  260. `;
  261. const Actions = styled('div')`
  262. margin-left: ${space(1)};
  263. `;