tableField.tsx 6.2 KB

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