form.js 27 KB

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