form.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  1. import { useEffect, useState, useMemo, useCallback, useId, useRef } from 'react';
  2. import { useForm, FormProvider } from 'react-hook-form';
  3. import { getFormFieldByData, isFormResponseComplete, normalizeFormResponseData, updateFormDirtyState, isFormUnsavedCloseGuardSkipped, formHasPendingUploads, clearFormPendingUploads, formResponseHasFieldErrors, FILES_FIELD_MIRRORS } from 'app/lib/form/form-helpers'
  4. import { getDefaultPasteStorageKey } from 'app/components/form-fields/media-attachments'
  5. import { markFormBaseline } from 'app/lib/form/use-form-field'
  6. import { getFormInitialValues } from 'app/lib/form/form-initial-values'
  7. import { View, Row } from 'app/design/view'
  8. import { components } from 'app/components/registry';
  9. import { FeedbackHaptics, appSetting, isNumeric, isObjectsEqual } from 'app/lib/util';
  10. import { playCue } from 'app/lib/sound';
  11. import { sessionCacheGet, sessionCacheSet } from 'app/lib/util/storage';
  12. import { Platform } from 'react-native';
  13. import emitter, { EVENTS } from 'app/context/emitter';
  14. import { FormInstanceProvider } from 'app/context/form-instance';
  15. import useDebounce from 'app/lib/hooks/use-debounce'
  16. import { NeoButton } from 'app/design/controls';
  17. import useFetchForm from 'app/lib/hooks/use-fetch-form'
  18. import { FormError } from 'app/components/form-fields/_field'
  19. import { useTranslation } from 'react-i18next'
  20. import { useRouter, usePathname, redirectTo } from 'app/lib/hooks/router'
  21. import { getTabKeyFromPathname } from 'app/lib/navigation/tab-history'
  22. const isWeb = Platform.OS === 'web';
  23. const EMPTY_WATCH = {};
  24. /** Resolve UNA redirect block URI (same rules as elements/redirect). */
  25. function resolveRedirectUri(redirectItem) {
  26. const rawUri = redirectItem?.data?.uri;
  27. if (!rawUri || rawUri === '/') return '/home';
  28. return rawUri;
  29. }
  30. function FormProviders({ instanceId, methods, children }) {
  31. return (
  32. <FormInstanceProvider instanceId={instanceId}>
  33. <FormProvider {...methods}>
  34. {children}
  35. </FormProvider>
  36. </FormInstanceProvider>
  37. );
  38. }
  39. // A form mapped by name wins; otherwise UNA's `form_type` (module entry forms) picks the layout.
  40. function getFormType(name, formType) {
  41. return components['form'][String(name)] ?? (formType ? components['form'][String(formType)] : undefined)
  42. }
  43. function getFormFieldList(name, inputs, handleSubmit, isInitial = false, lastChangedField, saveOnChanges, formProps, formType) {
  44. const ElementForm = getFormType(name, formType);
  45. if ('undefined' !== typeof ElementForm && isInitial) {
  46. return;
  47. }
  48. if (!inputs) {
  49. return;
  50. }
  51. const defaultStorageKey = getDefaultPasteStorageKey(inputs)
  52. return Object.keys(inputs).map(function (key) {
  53. return getFormFieldByData(
  54. inputs[key],
  55. handleSubmit,
  56. 'default',
  57. {
  58. ...formProps,
  59. form_name: name,
  60. last_changed: lastChangedField,
  61. saveOnChanges: saveOnChanges,
  62. ...(defaultStorageKey && key === defaultStorageKey ? { asDefaultStorage: true } : null),
  63. },
  64. key
  65. )
  66. });
  67. }
  68. const checkInputType = (name, form_name, input_name) => {
  69. const setting = appSetting('forms', name + '_control_names');
  70. if (setting && (setting.includes(form_name + '_' + input_name) || setting.includes('*_' + input_name)))
  71. return true;
  72. return false;
  73. }
  74. export default function Form({
  75. layout,
  76. data: initedData,
  77. name: formName,
  78. onFormEmpty,
  79. onFormSubmit: onFormSubmitInternal,
  80. formProps: initedFormProps,
  81. resetOnSubmit,
  82. isSubmit,
  83. onChange,
  84. saveOnChanges,
  85. exProps,
  86. request,
  87. url,
  88. form_type: formType,
  89. }) {
  90. const { t } = useTranslation();
  91. const router = useRouter();
  92. const pathname = usePathname();
  93. const isAutoChange = !!onChange;
  94. const { auto_focus, ...formProps } = initedFormProps ?? {};
  95. const { ...methods } = useForm({ mode: 'onChange' });
  96. // Proxy formState: only accessed properties subscribe. Reading isDirty on a
  97. // 30-field form (playground, create) re-renders the whole tree on every
  98. // setValue and can loop with editors that sync HTML back into RHF.
  99. const formInstanceId = useId();
  100. // Avoid double navigation if the response effect re-runs with the same redirect.
  101. const redirectedUriRef = useRef(null);
  102. const [postData, setPostData] = useState(null);
  103. const [formBundle, setFormBundle] = useState({
  104. form: initedData,
  105. extra: null,
  106. response: null,
  107. //request: request,
  108. });
  109. const name = formBundle.form?.params?.display?.includes('_delete') ? '' : (formName || formBundle.form?.params?.display)
  110. const skipUnsavedCloseGuard =
  111. exProps?.skipUnsavedCloseGuard === true || isFormUnsavedCloseGuardSkipped(name);
  112. const trackUnsavedDirty = !skipUnsavedCloseGuard && !isAutoChange;
  113. const isSubmitted = methods.formState.isSubmitted;
  114. const isSubmitSuccessful = methods.formState.isSubmitSuccessful;
  115. const isDirty = trackUnsavedDirty ? methods.formState.isDirty : false;
  116. useEffect(() => {
  117. // Auto-change forms (filters etc.) persist on every change, so they can
  118. // never hold unsaved changes — never let them block closing.
  119. if (!trackUnsavedDirty) {
  120. updateFormDirtyState(formInstanceId, false);
  121. return () => updateFormDirtyState(formInstanceId, false);
  122. }
  123. const dirty = isDirty && !isSubmitSuccessful;
  124. updateFormDirtyState(formInstanceId, dirty);
  125. return () => updateFormDirtyState(formInstanceId, false);
  126. }, [formInstanceId, isDirty, isSubmitSuccessful, trackUnsavedDirty]);
  127. // Pending uploads live in a module Map keyed by useId() — that id is often
  128. // reused when Create Post remounts, so orphaned ids would stick Publish in
  129. // loading until a full page refresh. Clear markers only (do not abort
  130. // network): ImagePicker remounts resume via uploadId + inFlightUploadIds.
  131. useEffect(() => {
  132. return () => clearFormPendingUploads(name, formInstanceId);
  133. }, [name, formInstanceId]);
  134. const { data: dynamicData, error: submitError } = useFetchForm(request?.url, postData);
  135. // A submit whose result makes no sound: the `isSubmit` auto-submit.
  136. const quietResultRef = useRef(false);
  137. useEffect(() => {
  138. if (submitError) {
  139. emitter.emit(EVENTS.form(name), { action: 'received', formInstanceId });
  140. }
  141. }, [submitError, name, formInstanceId]);
  142. // The result of a submit: subtle success once UNA takes it, error when it
  143. // rejects it (field errors, or a message with a non-zero code). A message
  144. // without a code can say either, so it plays nothing unless a redirect or
  145. // reload says the form went through. The next step of a wizard is neither.
  146. // Filter forms and forms that save every change stay quiet: their controls
  147. // sound already.
  148. useEffect(() => {
  149. if (!dynamicData && !submitError) return;
  150. const quiet = quietResultRef.current || isAutoChange || saveOnChanges;
  151. quietResultRef.current = false;
  152. if (quiet) return;
  153. const items = normalizeFormResponseData(dynamicData?.data);
  154. const message = items.find((item) => item?.type === 'msg');
  155. const hasCode = message?.code !== undefined && message?.code !== null;
  156. if (submitError || formResponseHasFieldErrors(dynamicData?.data) || (hasCode && Number(message.code) !== 0)) {
  157. playCue('error');
  158. return;
  159. }
  160. if (message && !hasCode && !items.some((item) => item?.type === 'redirect' || item?.reload)) return;
  161. const formItem = items.find((item) => item?.type === 'form');
  162. const nextDisplay = formItem?.data?.params?.display || formItem?.name;
  163. const currentDisplay = formName || formBundle.form?.params?.display;
  164. if (nextDisplay && currentDisplay && nextDisplay !== currentDisplay) return;
  165. playCue('success', { emphasis: 'subtle' });
  166. // eslint-disable-next-line react-hooks/exhaustive-deps -- once per reply
  167. }, [dynamicData, submitError]);
  168. // update state when form is submitted
  169. const onFormSubmit = onFormSubmitInternal ?? ((formData, d) => {
  170. if (request?.url) {
  171. setPostData(formData);
  172. }
  173. });
  174. useEffect(() => {
  175. if (!dynamicData) {
  176. // No dynamic data — revert to original
  177. setFormBundle(prev => ({
  178. ...prev,
  179. form: initedData,
  180. extra: null,
  181. response: null,
  182. }));
  183. return;
  184. }
  185. const rawData = dynamicData.data;
  186. if (isFormResponseComplete(rawData)) {
  187. setFormBundle(prev => ({
  188. ...prev,
  189. form: { ...prev.form, completed: Date.now() },
  190. extra: null,
  191. response: null,
  192. }));
  193. emitter.emit(EVENTS.form(name), { action: 'received', formInstanceId, data: rawData });
  194. return;
  195. }
  196. const items = normalizeFormResponseData(rawData);
  197. if (!items.length) return;
  198. const formItem = items.find(item => item?.type === 'form');
  199. const redirectItem = items.find(item => item?.type === 'redirect');
  200. // Prefer non-redirect extras for Element rendering; redirect is handled below
  201. // (formOnly modals close before ElementRedirect can run its effect).
  202. const otherItem = items.find(
  203. item => item?.type && item?.type !== 'form' && item?.type !== 'redirect'
  204. );
  205. // hide_on_msg: replace form only when response is msg-only (success).
  206. // If UNA also returns a form (validation errors), keep the form and drop the msg.
  207. const hideOnMsg = !!(appSetting('forms', name) || {}).hide_on_msg;
  208. if (redirectItem) {
  209. const uri = resolveRedirectUri(redirectItem);
  210. if (redirectedUriRef.current !== uri) {
  211. redirectedUriRef.current = uri;
  212. redirectTo(router, uri, getTabKeyFromPathname(pathname));
  213. }
  214. }
  215. setFormBundle(prev => {
  216. const nextForm = formItem?.data
  217. ? { ...formItem.data, updated: Date.now() }
  218. : { ...prev.form, updated: Date.now() };
  219. const nextResponse = formItem?.response ?? prev.response;
  220. // Redirect is navigated above — do not mount ElementRedirect (double nav /
  221. // formOnly unmount race). Keep msg and other extras as usual.
  222. const nextExtra = hideOnMsg && formItem && formResponseHasFieldErrors(rawData)
  223. ? null
  224. : (otherItem ?? (redirectItem ? null : prev.extra));
  225. // Optional: skip setState when nothing actually changed
  226. const isSameForm = isObjectsEqual(prev.form, nextForm);
  227. const isSameExtra = isObjectsEqual(prev.extra, nextExtra);
  228. const isSameResponse = prev.response === nextResponse;
  229. if (isSameForm && isSameExtra && isSameResponse) {
  230. return prev;
  231. }
  232. return {
  233. ...prev,
  234. form: nextForm,
  235. extra: nextExtra,
  236. response: nextResponse,
  237. };
  238. });
  239. emitter.emit(EVENTS.form(name), { action: 'received', formInstanceId, data: rawData });
  240. }, [dynamicData, initedData, formInstanceId, name, router, pathname]);
  241. useEffect(() => {
  242. if (!onFormEmpty || !dynamicData) return;
  243. if (isFormResponseComplete(dynamicData.data)) {
  244. onFormEmpty();
  245. return;
  246. }
  247. // Modal / formOnly: UNA often returns msg, redirect, or the same form
  248. // again with no field errors — treat those as done so parents can reload.
  249. // A different form (wizard / next step) must keep the modal open.
  250. if (!exProps?.formOnly) return;
  251. const items = normalizeFormResponseData(dynamicData.data);
  252. if (!items.length) {
  253. onFormEmpty();
  254. return;
  255. }
  256. const formItem = items.find((item) => item?.type === 'form');
  257. const nextDisplay = formItem?.data?.params?.display || formItem?.name;
  258. const currentDisplay = formName || formBundle.form?.params?.display;
  259. const isNextStepForm =
  260. !!formItem &&
  261. !!nextDisplay &&
  262. !!currentDisplay &&
  263. nextDisplay !== currentDisplay;
  264. if (isNextStepForm) return;
  265. if (formResponseHasFieldErrors(dynamicData.data)) return;
  266. const done = items.some(
  267. (item) =>
  268. item?.type === 'msg' ||
  269. item?.type === 'redirect' ||
  270. item?.type === 'form'
  271. );
  272. if (done) onFormEmpty();
  273. }, [onFormEmpty, dynamicData, exProps?.formOnly, formName, formBundle.form?.params?.display]);
  274. const { processedInputs, defaultValues } = useMemo(() => {
  275. const dv = {};
  276. const processed = {};
  277. const inputs = formBundle.form?.inputs;
  278. if (!inputs) {
  279. return { processedInputs: null, defaultValues: dv };
  280. }
  281. const inputKeys = Object.keys(inputs);
  282. let isAutofocusEnabledForForm = auto_focus === true;
  283. for (const key of inputKeys) {
  284. const src = inputs[key];
  285. if (!src) continue;
  286. // Copy — do NOT mutate the original
  287. const input = { ...src };
  288. // switcher/checkbox: false > value = 0
  289. if (
  290. (input.type === 'switcher' || input.type === 'checkbox') &&
  291. input.checked === false
  292. ) {
  293. input.value = 0;
  294. }
  295. // autofocus only the first text/textarea field
  296. if (input.type === 'text' || input.type === 'textarea') {
  297. if (isAutofocusEnabledForForm) {
  298. input.auto_focus = true;
  299. isAutofocusEnabledForForm = false;
  300. } else {
  301. input.auto_focus = false;
  302. }
  303. }
  304. // visibility / selector
  305. ['visibility', 'selector'].forEach(type => {
  306. if (checkInputType(type, name, input.name)) {
  307. input.origtype = input.origtype || input.type;
  308. input.type = type;
  309. }
  310. });
  311. // defaultValues — price comes as `{ value, currency }` from UNA API
  312. if (input.type === 'price') {
  313. const amount =
  314. input.value != null && typeof input.value === 'object'
  315. ? input.value.value
  316. : input.value;
  317. dv[key] = amount == null || amount === '' ? '' : String(amount);
  318. } else if (input.value || input.value === 0) {
  319. dv[key] = input.value;
  320. }
  321. processed[key] = input;
  322. }
  323. return { processedInputs: processed, defaultValues: dv };
  324. }, [formBundle.form?.inputs, auto_focus, name]);
  325. useEffect(() => {
  326. if (!processedInputs) return;
  327. Object.entries(processedInputs).forEach(([key, input]) => {
  328. if (!input?.error) return;
  329. const message = Array.isArray(input.error) ? input.error[0] : input.error;
  330. const link = Array.isArray(input.error) ? input.error[1] : null;
  331. if (message) {
  332. methods.setError(key, {
  333. type: 'server',
  334. message,
  335. ...(link && { link }),
  336. });
  337. }
  338. });
  339. // eslint-disable-next-line react-hooks/exhaustive-deps
  340. }, [processedInputs]);
  341. // Form default values = every field's untouched value (field-initial-values),
  342. // set as soon as the form data arrives — code-split fields may not have
  343. // loaded yet, so isDirty must not depend on what has mounted. RHF does not
  344. // merge a field's own defaultValue into the form defaults. Types without an
  345. // initial-value rule keep what they hold now; later ones baseline themselves
  346. // at mount (useFieldController).
  347. const isFirstResetRef = useRef(true);
  348. useEffect(() => {
  349. if (!processedInputs) return;
  350. const initialValues = getFormInitialValues(processedInputs);
  351. if (isFirstResetRef.current) {
  352. isFirstResetRef.current = false;
  353. methods.reset({ ...methods.getValues(), ...initialValues }, {
  354. keepErrors: true,
  355. keepTouched: true,
  356. keepIsSubmitted: true,
  357. keepSubmitCount: true,
  358. });
  359. markFormBaseline(methods.control);
  360. return;
  361. }
  362. if (!formBundle?.form?.updated) return;
  363. const legacyValues = Object.keys(processedInputs).reduce((result, key) => {
  364. const input = processedInputs[key];
  365. if (input?.value || input?.value === 0) {
  366. result[key] = input.value;
  367. } else if (input?.type === 'switcher' || input?.type === 'checkbox') {
  368. result[key] = input.checked ? input.value : '0';
  369. } else {
  370. result[key] = '';
  371. }
  372. return result;
  373. }, {});
  374. const values = { ...legacyValues, ...initialValues };
  375. // Validation errors: UNA re-renders the form without the files uploaded for this
  376. // submit, so keep what the files fields (and the ids they mirror) hold now.
  377. if (formResponseHasFieldErrors(dynamicData?.data)) {
  378. for (const [key, input] of Object.entries(processedInputs)) {
  379. if (input?.type !== 'files') continue;
  380. for (const k of [key, FILES_FIELD_MIRRORS[key]]) {
  381. if (k) values[k] = methods.getValues(k) ?? '';
  382. }
  383. }
  384. }
  385. // No keepDefaultValues: the server-provided values become the new baseline,
  386. // so a just-updated (untouched) form is not treated as dirty by the
  387. // unsaved-changes close guard.
  388. methods.reset(values);
  389. methods.clearErrors();
  390. // eslint-disable-next-line react-hooks/exhaustive-deps
  391. }, [processedInputs, formBundle?.form?.updated]);
  392. const { csrf_token, ...restDefaultValues } = defaultValues;
  393. // Include page url (not uri — uri can be shared across different pages) so
  394. // auto-change filter drafts do not bleed between routes.
  395. const cacheKey = `${formName}_${request?.url || false}_${url || ''}`;
  396. const onSubmit = async d => {
  397. FeedbackHaptics('Medium')
  398. const formData = new FormData();
  399. Object.keys(d).map(function (key) {
  400. formData.append(key, d[key]);
  401. //if (processedInputs[key])
  402. // processedInputs[key].value = d[key];
  403. });
  404. await onFormSubmit(formData, d);
  405. }
  406. const onError = async d => {
  407. // A field failed validation before the submit left the browser.
  408. playCue('error', { emphasis: 'subtle' });
  409. }
  410. useEffect(() => {
  411. if (methods.formState.isSubmitSuccessful) {
  412. if (resetOnSubmit)
  413. methods.reset();
  414. }
  415. }, [methods.formState, methods.submittedData, methods.reset]);
  416. const _handleSubmit = useMemo(
  417. () =>
  418. methods.handleSubmit(
  419. (data) => {
  420. if (formHasPendingUploads(name, formInstanceId)) {
  421. return;
  422. }
  423. // awaitsResponse: the form posts to UNA itself and emits 'received' with the reply.
  424. emitter.emit(EVENTS.form(name), { action: 'submited', formInstanceId, awaitsResponse: !onFormSubmitInternal });
  425. onSubmit(data);
  426. if (onFormSubmitInternal) {
  427. emitter.emit(EVENTS.form(name), { action: 'received', formInstanceId });
  428. }
  429. },
  430. onError
  431. ),
  432. [methods, name, formInstanceId, onSubmit, onError, onFormSubmitInternal]
  433. );
  434. const handleKeyUp = useCallback((event) => {
  435. if (event.key !== 'Enter' && event.keyCode !== 13) return;
  436. // TipTap is a DIV; after submit it blurs and keyup lands on BODY,
  437. // which used to submit the now-empty comment form a second time.
  438. if (event.target?.tagName !== 'INPUT') return;
  439. _handleSubmit();
  440. }, [_handleSubmit]);
  441. useEffect(() => {
  442. if (isWeb) {
  443. window.addEventListener('keyup', handleKeyUp);
  444. return () => {
  445. window.removeEventListener('keyup', handleKeyUp);
  446. };
  447. }
  448. }, [handleKeyUp]);
  449. useEffect(() => {
  450. if (!formBundle?.form?.reset) return;
  451. if (defaultValues['cmt_parent_id'] === undefined) return;
  452. const id = setTimeout(() => {
  453. methods.setValue('cmt_parent_id', defaultValues['cmt_parent_id']);
  454. }, 100);
  455. return () => clearTimeout(id);
  456. }, [formBundle?.form?.reset, defaultValues['cmt_parent_id'], methods]);
  457. useEffect(() => {
  458. if (isSubmit) {
  459. quietResultRef.current = true;
  460. _handleSubmit();
  461. }
  462. }, [isSubmit, _handleSubmit]);
  463. // watch() subscribes the whole Form to every field write. Only filter
  464. // (onChange) forms need that; kitchen-sink / regular submit forms do not.
  465. const allFields = isAutoChange ? methods.watch() : EMPTY_WATCH;
  466. const debouncedFields = useDebounce(allFields, 500);
  467. useEffect(() => {
  468. if (isAutoChange && onChange && Object.keys(debouncedFields).length > 0) {
  469. sessionCacheSet('form:' + cacheKey, debouncedFields);
  470. onChange(debouncedFields);
  471. }
  472. }, [debouncedFields, isAutoChange, onChange, cacheKey]);
  473. useEffect(() => {
  474. if (isAutoChange && onChange) {
  475. if (!processedInputs) return
  476. const raw = cacheKey ? sessionCacheGet('form:' + cacheKey) : null
  477. if (raw) {
  478. const draft = typeof raw === 'string' ? JSON.parse(raw) : raw
  479. const current = methods.getValues();
  480. const updates = {};
  481. const keysWithHtml = Object.keys(processedInputs).filter(
  482. (key) => processedInputs[key].html > 0
  483. );
  484. keysWithHtml.forEach((key) => { // HUCK FOR rtf inputs
  485. if (draft[key] !== undefined) {
  486. // updates[key] = '<!--INITED-->' + draft[key];
  487. }
  488. });
  489. methods.reset({ ...current, ...draft, ...updates }, { keepDefaultValues: true });
  490. }
  491. }
  492. }, [isAutoChange, onChange, cacheKey, processedInputs]);
  493. const filteredInputs = useMemo(() => {
  494. if (!processedInputs) return null;
  495. if (!isAutoChange) return processedInputs;
  496. const result = {};
  497. Object.entries(processedInputs).forEach(([key, input]) => {
  498. if (!input || typeof input !== 'object') return;
  499. if (input.type === 'submit') return;
  500. if (input.name === 'csrf_token') return;
  501. result[key] = input;
  502. });
  503. return result;
  504. }, [processedInputs, isAutoChange]);
  505. let inputs = getFormFieldList(name, filteredInputs, _handleSubmit, true, null, saveOnChanges, formProps, formType);
  506. if (inputs?.length) {
  507. inputs = inputs.filter(
  508. item =>
  509. (item.key !== null && item.key.toString() !== '') ||
  510. item.props.type === 'block_end'
  511. );
  512. const lastNonHiddenIndexFromEnd = [...inputs].reverse().findIndex(
  513. input => input.props?.type !== 'hidden'
  514. );
  515. const lastNonHiddenIndex = lastNonHiddenIndexFromEnd === -1 ? -1 : inputs.length - 1 - lastNonHiddenIndexFromEnd;
  516. inputs = inputs.map((input, index) => ({
  517. ...input,
  518. props: {
  519. ...input.props,
  520. form_layout: layout,
  521. use_caption_as_placeholder:
  522. input.props.use_caption_as_placeholder ??
  523. appSetting('forms', 'without_captions').includes(name),
  524. ...(index === lastNonHiddenIndex && { noPadding: true }),
  525. },
  526. }));
  527. }
  528. const ElementForm = getFormType(name, formType)
  529. const defaultFormValues = Object.keys(allFields).reduce((result, key) => {
  530. const field = filteredInputs?.[key];
  531. const fieldType = field?.type;
  532. const hasDefaultValue = key in defaultValues;
  533. if (fieldType === 'location') {
  534. result[key] = '';
  535. return result;
  536. }
  537. if (fieldType === 'switcher') {
  538. result[key] = field?.checked || '0';
  539. return result;
  540. }
  541. result[key] = hasDefaultValue ? defaultValues[key] : '';
  542. return result;
  543. }, {});
  544. const currentFormValues = filteredInputs ? Object.keys(filteredInputs).reduce((result, key) => {
  545. if (filteredInputs?.[key].type !== 'location') {
  546. result[key] = isNumeric(allFields[key]) ? allFields[key].toString() : allFields[key];
  547. }
  548. else {
  549. if (allFields[key + '_country'])
  550. result[key] = allFields[key + '_country'];
  551. else
  552. result[key] = '';
  553. }
  554. return result;
  555. }, {}) : [];
  556. const Element = formBundle.extra ? components['element'][String(formBundle.extra.type)] : null;
  557. const formSettings = appSetting('forms', name) || {};
  558. const hideFormOnMsg = formSettings.hide_on_msg && formBundle.extra?.type === 'msg';
  559. function stableStringify(obj) {
  560. return JSON.stringify(
  561. Object.keys(obj).sort().reduce((acc, k) => (acc[k] = obj[k], acc), {})
  562. );
  563. }
  564. const errorMessage = submitError ? (
  565. <FormError errorText={t('Something didn’t go as planned. Please try again or reload the page.')} />
  566. ) : null;
  567. if ('undefined' !== typeof ElementForm) {
  568. inputs = <ElementForm name={name} data={{ ...formBundle?.form, inputs: filteredInputs }} response={formBundle.response} handleSubmit={_handleSubmit} exProps={exProps}></ElementForm>
  569. return (
  570. <FormProviders instanceId={formInstanceId} methods={methods}>
  571. {Element && <Element {...formBundle.extra} />}
  572. {errorMessage}
  573. {!hideFormOnMsg ? inputs : null}
  574. </FormProviders>
  575. )
  576. }
  577. return (
  578. <FormProviders instanceId={formInstanceId} methods={methods}>
  579. <View className={`${layout !== 'hor' ? appSetting('forms', 'form_container') : 'w-full'} ${exProps?.classes}`}>
  580. {Element && <Element {...formBundle.extra} />}
  581. {errorMessage}
  582. {!hideFormOnMsg ? (
  583. <View className={`${layout === 'hor' ? 'flex-row gap-x-4 items-center w-full' : 'w-full gap-4'}`}>
  584. {inputs}
  585. {(isAutoChange) && <Row className={`items-center justify-between ${layout === 'hor' ? ' ' : ' '} `}>
  586. {(stableStringify(defaultFormValues) != stableStringify(currentFormValues)) && <NeoButton
  587. controlSize="small"
  588. width="fill"
  589. image="X"
  590. label={t('Reset Filters')}
  591. onPress={() => {
  592. if (isWeb) {
  593. const url = new URL(window.location.href);
  594. if (url.searchParams.has('filters')) {
  595. url.searchParams.delete('filters');
  596. window.location.replace(`${url.pathname}${url.search}${url.hash}`);
  597. }
  598. }
  599. methods.reset();
  600. }}
  601. />
  602. }
  603. </Row>}
  604. </View>
  605. ) : null}
  606. </View>
  607. </FormProviders>
  608. );
  609. }