tableField.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import {Component, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import flatten from 'lodash/flatten';
  4. import Alert from 'sentry/components/alert';
  5. import Button from 'sentry/components/button';
  6. import Confirm from 'sentry/components/confirm';
  7. import FormField from 'sentry/components/forms/formField';
  8. import {TableType} from 'sentry/components/forms/types';
  9. import Input from 'sentry/components/input';
  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 {singleLineRenderer} from 'sentry/utils/marked';
  15. // XXX(epurkhiser): This is wrong, it should not be inheriting these props
  16. import {InputFieldProps} from './inputField';
  17. interface DefaultProps {
  18. /**
  19. * Text used for the 'add' button. An empty string can be used
  20. * to just render the "+" icon.
  21. */
  22. addButtonText: string;
  23. /**
  24. * Automatically save even if fields are empty
  25. */
  26. allowEmpty: boolean;
  27. }
  28. export interface TableFieldProps extends Omit<InputFieldProps, 'type'> {}
  29. interface RenderProps extends TableFieldProps, DefaultProps, Omit<TableType, 'type'> {}
  30. const DEFAULT_PROPS: DefaultProps = {
  31. addButtonText: t('Add Item'),
  32. allowEmpty: false,
  33. };
  34. export default class TableField extends Component<InputFieldProps> {
  35. static defaultProps = DEFAULT_PROPS;
  36. hasValue = value => defined(value) && !objectIsEmpty(value);
  37. renderField = (props: RenderProps) => {
  38. const {
  39. onChange,
  40. onBlur,
  41. addButtonText,
  42. columnLabels,
  43. columnKeys,
  44. disabled: rawDisabled,
  45. allowEmpty,
  46. confirmDeleteMessage,
  47. } = props;
  48. const mappedKeys = columnKeys || [];
  49. const emptyValue = mappedKeys.reduce((a, v) => ({...a, [v]: null}), {id: ''});
  50. const valueIsEmpty = this.hasValue(props.value);
  51. const value = valueIsEmpty ? (props.value as any[]) : [];
  52. const saveChanges = (nextValue: object[]) => {
  53. onChange?.(nextValue, []);
  54. // nextValue is an array of ObservableObjectAdministration objects
  55. const validValues = !flatten(Object.values(nextValue).map(Object.entries)).some(
  56. ([key, val]) => key !== 'id' && !val // don't allow empty values except if it's the ID field
  57. );
  58. if (allowEmpty || validValues) {
  59. // TOOD: add debouncing or use a form save button
  60. onBlur?.(nextValue, []);
  61. }
  62. };
  63. const addRow = () => {
  64. saveChanges([...value, emptyValue]);
  65. };
  66. const removeRow = rowIndex => {
  67. const newValue = [...value];
  68. newValue.splice(rowIndex, 1);
  69. saveChanges(newValue);
  70. };
  71. const setValue = (
  72. rowIndex: number,
  73. fieldKey: string,
  74. fieldValue: React.FormEvent<HTMLInputElement>
  75. ) => {
  76. const newValue = [...value];
  77. newValue[rowIndex][fieldKey] = fieldValue.currentTarget
  78. ? fieldValue.currentTarget.value
  79. : null;
  80. saveChanges(newValue);
  81. };
  82. // should not be a function for this component
  83. const disabled = typeof rawDisabled === 'function' ? false : rawDisabled;
  84. const button = (
  85. <Button
  86. icon={<IconAdd size="xs" isCircled />}
  87. onClick={addRow}
  88. size="xs"
  89. disabled={disabled}
  90. >
  91. {addButtonText}
  92. </Button>
  93. );
  94. // The field will be set to inline when there is no value set for the
  95. // field, just show the button.
  96. if (!valueIsEmpty) {
  97. return <div>{button}</div>;
  98. }
  99. const renderConfirmMessage = () => {
  100. return (
  101. <Fragment>
  102. <Alert type="error">
  103. <span
  104. dangerouslySetInnerHTML={{
  105. __html: singleLineRenderer(
  106. confirmDeleteMessage || t('Are you sure you want to delete this item?')
  107. ),
  108. }}
  109. />
  110. </Alert>
  111. </Fragment>
  112. );
  113. };
  114. return (
  115. <Fragment>
  116. <HeaderContainer>
  117. {mappedKeys.map((fieldKey, i) => (
  118. <Header key={fieldKey}>
  119. <HeaderLabel>{columnLabels?.[fieldKey]}</HeaderLabel>
  120. {i === mappedKeys.length - 1 && button}
  121. </Header>
  122. ))}
  123. </HeaderContainer>
  124. {value.map((row, rowIndex) => (
  125. <RowContainer data-test-id="field-row" key={rowIndex}>
  126. {mappedKeys.map((fieldKey: string, i: number) => (
  127. <Row key={fieldKey}>
  128. <RowInput>
  129. <Input
  130. onChange={v => setValue(rowIndex, fieldKey, v)}
  131. value={!defined(row[fieldKey]) ? '' : row[fieldKey]}
  132. />
  133. </RowInput>
  134. {i === mappedKeys.length - 1 && (
  135. <Confirm
  136. priority="danger"
  137. disabled={disabled}
  138. onConfirm={() => removeRow(rowIndex)}
  139. message={renderConfirmMessage()}
  140. >
  141. <RemoveButton>
  142. <Button
  143. icon={<IconDelete />}
  144. size="sm"
  145. disabled={disabled}
  146. aria-label={t('delete')}
  147. />
  148. </RemoveButton>
  149. </Confirm>
  150. )}
  151. </Row>
  152. ))}
  153. </RowContainer>
  154. ))}
  155. </Fragment>
  156. );
  157. };
  158. render() {
  159. // We need formatMessageValue=false since we're saving an object
  160. // and there isn't a great way to render the
  161. // change within the toast. Just turn off displaying the from/to portion of
  162. // the message
  163. return (
  164. <FormField
  165. {...this.props}
  166. formatMessageValue={false}
  167. inline={({model}) => !this.hasValue(model.getValue(this.props.name))}
  168. >
  169. {this.renderField}
  170. </FormField>
  171. );
  172. }
  173. }
  174. const HeaderLabel = styled('div')`
  175. font-size: 0.8em;
  176. text-transform: uppercase;
  177. color: ${p => p.theme.subText};
  178. `;
  179. const HeaderContainer = styled('div')`
  180. display: flex;
  181. align-items: center;
  182. `;
  183. const Header = styled('div')`
  184. display: flex;
  185. flex: 1 0 0;
  186. align-items: center;
  187. justify-content: space-between;
  188. `;
  189. const RowContainer = styled('div')`
  190. display: flex;
  191. align-items: center;
  192. margin-top: ${space(1)};
  193. `;
  194. const Row = styled('div')`
  195. display: flex;
  196. flex: 1 0 0;
  197. align-items: center;
  198. margin-top: ${space(1)};
  199. `;
  200. const RowInput = styled('div')`
  201. flex: 1;
  202. margin-right: ${space(1)};
  203. `;
  204. const RemoveButton = styled('div')`
  205. margin-left: ${space(1)};
  206. `;