choiceMapperField.tsx 8.1 KB

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