submit.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. import { useCallback, useState, useEffect, useRef } from 'react';
  2. import { Keyboard, Platform } from 'react-native';
  3. import Field, { FormError } from './_field';
  4. import { useFormContext } from 'react-hook-form';
  5. import { NeoButton, Hidden } from 'app/design/controls';
  6. import { View, Row } from 'app/design/view';
  7. import { appSetting, cn } from 'app/lib/util';
  8. import emitter, { EVENTS } from 'app/context/emitter';
  9. import { useFormInstanceId } from 'app/context/form-instance';
  10. import { useFormUploading } from 'app/lib/form/form-helpers';
  11. import { useFormField } from 'app/lib/form/use-form-field';
  12. import { useTranslation } from 'react-i18next';
  13. const isNative = Platform.OS !== 'web';
  14. const SIZE_TO_CONTROL = {
  15. xs: 'mini',
  16. sm: 'small',
  17. base: 'regular',
  18. lg: 'large',
  19. };
  20. /** Full-width in narrow form containers; hug content when the form is wider (matches legacy Button). */
  21. const RESPONSIVE_SUBMIT_WIDTH = 'w-full @sm/form-container:w-auto flex-auto web:w-full';
  22. const RESPONSIVE_SUBMIT_CONTAINER = 'w-full @sm/form-container:w-auto';
  23. const VARIANT_TO_STYLE = {
  24. primary: 'borderedProminent',
  25. secondary: 'bordered',
  26. outline: 'bordered',
  27. default: 'glassProminent',
  28. };
  29. export default function FormFieldSubmit(props) {
  30. const { t } = useTranslation();
  31. const {
  32. value,
  33. form_name,
  34. handleSubmit,
  35. disabled = false,
  36. icon_only = false,
  37. rounded = false,
  38. icon,
  39. size = 'base',
  40. variant,
  41. style: neoStyle,
  42. alt,
  43. tooltip,
  44. notFullWidth = false,
  45. saveOnChanges = false,
  46. hide_errors = true,
  47. bare = false,
  48. } = props;
  49. const { name, field, readOnly } = useFormField(props, { returnKey: false });
  50. const formContext = useFormContext();
  51. const formInstanceId = useFormInstanceId();
  52. const { formState } = formContext;
  53. const [isSumbitting, setIsSumbitting] = useState(false);
  54. const isUploading = useFormUploading(form_name);
  55. // Guards against onPressIn + onPress both firing on the same gesture.
  56. const pressLockRef = useRef(false);
  57. const formProps = appSetting('forms', form_name) || {};
  58. const handlePress = useCallback(() => {
  59. if (formState.isSubmitting || disabled || readOnly || isUploading || isSumbitting || pressLockRef.current) return;
  60. pressLockRef.current = true;
  61. if (isNative) Keyboard.dismiss();
  62. handleSubmit();
  63. // Unlock after the gesture settles so a failed validation can be retried,
  64. // but not so fast that the matching onPress double-fires.
  65. setTimeout(() => {
  66. pressLockRef.current = false;
  67. }, 400);
  68. }, [disabled, readOnly, handleSubmit, formState.isSubmitting, isUploading, isSumbitting]);
  69. const handleReset = useCallback(() => {
  70. const values = formContext.getValues();
  71. Object.keys(values).forEach((key) => {
  72. if (key !== 'key') {
  73. formContext.setValue(key, '');
  74. }
  75. });
  76. handleSubmit();
  77. }, [formContext, handleSubmit]);
  78. const errors = formState.errors;
  79. const errorKeys = Object.keys(errors);
  80. const showErrors =
  81. errorKeys.length > 0 && !hide_errors && !formProps.hide_errors;
  82. const fullWidth = !notFullWidth;
  83. const controlSize = SIZE_TO_CONTROL[size] || 'regular';
  84. const buttonStyle =
  85. neoStyle ?? (variant ? VARIANT_TO_STYLE[variant] : undefined) ?? 'borderedProminent';
  86. const borderShape = icon_only && rounded
  87. ? 'circle'
  88. : rounded
  89. ? 'capsule'
  90. : 'roundedRectangle';
  91. useEffect(() => {
  92. let timeoutId;
  93. const subscription = emitter.addListener(EVENTS.form(form_name), (data) => {
  94. if (formInstanceId != null && data.formInstanceId !== formInstanceId) return;
  95. if (data.action == 'submited') {
  96. setIsSumbitting(true);
  97. }
  98. if (data.action == 'received') {
  99. timeoutId = setTimeout(() => {
  100. setIsSumbitting(false);
  101. }, 1000);
  102. }
  103. });
  104. return () => {
  105. if (timeoutId) clearTimeout(timeoutId);
  106. subscription.remove();
  107. };
  108. }, [form_name, formInstanceId]);
  109. const rowClassName = cn(
  110. formProps.button_hide_on_small || props.button_hide_on_small ? 'hidden sm:flex' : '',
  111. 'gap-x-2',
  112. 'items-center',
  113. );
  114. const responsiveButtonClassNames = fullWidth && !icon_only
  115. ? { root: RESPONSIVE_SUBMIT_WIDTH, container: RESPONSIVE_SUBMIT_CONTAINER }
  116. : undefined;
  117. const isBusy = isSumbitting || isUploading;
  118. const submitButton = (
  119. <NeoButton
  120. label={icon_only ? undefined : value}
  121. image={icon}
  122. loading={isBusy}
  123. style={buttonStyle}
  124. controlSize={controlSize}
  125. borderShape={borderShape}
  126. width="auto"
  127. alt={alt}
  128. tooltip={tooltip}
  129. classNames={responsiveButtonClassNames}
  130. onPress={handlePress}
  131. // Native: fire on press-in so keyboard layout jump cannot cancel onPress.
  132. {...(isNative ? { onPressIn: handlePress } : {})}
  133. disabled={isBusy || disabled || readOnly}
  134. />
  135. );
  136. const hiddenField = (
  137. <Hidden
  138. name={name}
  139. onChangeText={field.onChange}
  140. onBlur={field.onBlur}
  141. value={String(field.value)}
  142. />
  143. );
  144. if (bare) {
  145. return (
  146. <>
  147. {submitButton}
  148. {hiddenField}
  149. </>
  150. );
  151. }
  152. return (
  153. <Field {...props}>
  154. <Row className={rowClassName}>
  155. <View className={icon_only || notFullWidth ? '' : RESPONSIVE_SUBMIT_CONTAINER}>
  156. {submitButton}
  157. </View>
  158. {saveOnChanges && (
  159. <NeoButton
  160. label={t('Reset')}
  161. style="bordered"
  162. controlSize={controlSize}
  163. borderShape={borderShape}
  164. width="auto"
  165. classNames={responsiveButtonClassNames}
  166. onPress={handleReset}
  167. />
  168. )}
  169. </Row>
  170. {hiddenField}
  171. {showErrors && (
  172. <View className="mt-2">
  173. <FormError errorText={t('Errors:')} />
  174. <View className="ml-4">
  175. {errorKeys.map((fieldName) => (
  176. <FormError
  177. key={fieldName}
  178. errorText={errors[fieldName].message}
  179. />
  180. ))}
  181. </View>
  182. </View>
  183. )}
  184. </Field>
  185. );
  186. }