tableField.tsx 6.3 KB

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