choiceMapperField.tsx 8.2 KB

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