diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2f5257..a76c23a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - blur autocomplete when an option is clicked +- form element to display options as soon as possible instead of wait for all dynamic options to be loaded ## [4.2.0] - 2023-07-12 diff --git a/package-lock.json b/package-lock.json index a4a97405..7c69ceb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "@mui/lab": "^5.0.0-alpha.135", "@mui/material": "^5.10.1", "@mui/x-date-pickers": "^6.9.1", - "@oneblink/apps": "^5.2.0-beta.1", + "@oneblink/apps": "^6.0.0-beta.1", "@oneblink/release-cli": "^2.0.2", "@oneblink/types": "github:oneblink/types", "@types/blueimp-load-image": "^5.16.0", @@ -5222,13 +5222,14 @@ } }, "node_modules/@oneblink/apps": { - "version": "5.2.0-beta.1", + "version": "6.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@oneblink/apps/-/apps-6.0.0-beta.1.tgz", + "integrity": "sha512-40oftc2vfAYSUPSl5O5xwzepa4wkiaExGks3BMFE26vSIJkP+g8SkvXgGoT9SIi3+tSCEVSjDUkhnaMgiYQFXA==", "dev": true, - "license": "GPL-3.0-only", "dependencies": { "@aws-sdk/client-s3": "^3.363.0", "@aws-sdk/lib-storage": "^3.363.0", - "@oneblink/sdk-core": "^3.1.0-beta.4", + "@oneblink/sdk-core": "^4.0.0-beta.1", "@sentry/browser": "^6.19.7", "@sentry/tracing": "^6.19.7", "aws-sdk": "^2.1126.0", @@ -5250,6 +5251,16 @@ "npm": ">=8" } }, + "node_modules/@oneblink/apps/node_modules/@oneblink/sdk-core": { + "version": "4.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@oneblink/sdk-core/-/sdk-core-4.0.0-beta.1.tgz", + "integrity": "sha512-fvpHSVVvpRdHgWkVSt26jtQfKr1a0qkLM6qEldO0EWrz4M4vvDiJsVwc/NXwru6z63KY0ElClVq1ojHlIIr96w==", + "dev": true, + "engines": { + "node": ">=16", + "npm": ">=8" + } + }, "node_modules/@oneblink/apps/node_modules/nanoid": { "version": "4.0.1", "dev": true, diff --git a/package.json b/package.json index d28f01ec..bd705ab5 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@mui/lab": "^5.0.0-alpha.135", "@mui/material": "^5.10.1", "@mui/x-date-pickers": "^6.9.1", - "@oneblink/apps": "^5.2.0-beta.1", + "@oneblink/apps": "^6.0.0-beta.1", "@oneblink/release-cli": "^2.0.2", "@oneblink/types": "github:oneblink/types", "@types/blueimp-load-image": "^5.16.0", @@ -136,4 +136,4 @@ "types": "npm i -D github:oneblink/types", "typescript": "tsc --noEmit" } -} +} \ No newline at end of file diff --git a/src/OneBlinkFormBase.tsx b/src/OneBlinkFormBase.tsx index a92c3748..41e60391 100644 --- a/src/OneBlinkFormBase.tsx +++ b/src/OneBlinkFormBase.tsx @@ -13,7 +13,6 @@ import { attachmentsService } from '@oneblink/apps' import * as H from 'history' import Modal from './components/renderer/Modal' -import OneBlinkAppsErrorOriginalMessage from './components/renderer/OneBlinkAppsErrorOriginalMessage' import cleanFormSubmissionModel from './services/cleanFormSubmissionModel' import PageFormElements from './components/renderer/PageFormElements' import useFormValidation from './hooks/useFormValidation' @@ -23,7 +22,7 @@ import useLookups from './hooks/useLookups' import { FormDefinitionContext } from './hooks/useFormDefinition' import { InjectPagesContext } from './hooks/useInjectPages' import { ExecutedLookupProvider } from './hooks/useExecutedLookupCallback' -import useDynamicOptionsLoaderState from './hooks/useDynamicOptionsLoaderState' +import { FormElementOptionsContextProvider } from './hooks/useDynamicOptionsLoaderState' import { GoogleMapsApiKeyContext } from './hooks/useGoogleMapsApiKey' import { AbnLookupAuthenticationGuidContext } from './hooks/useAbnLookupAuthenticationGuid' import { CaptchaSiteKeyContext } from './hooks/useCaptchaSiteKey' @@ -363,19 +362,6 @@ function OneBlinkFormBase({ // // - // - // - // #region Dynamic Options - - const loadDynamicOptionsState = useDynamicOptionsLoaderState( - definition, - setFormSubmission, - ) - - // #endregion - // - // - // // // #region Submissions @@ -760,438 +746,436 @@ function OneBlinkFormBase({ ) } - if (loadDynamicOptionsState) { - return ( - <> -
- error -

{loadDynamicOptionsState.error.title}

-

{loadDynamicOptionsState.error.message}

-

- {localisationService.formatDatetimeLong(new Date())} -

-
- - - - ) - } - return ( -
-
handleSubmit(e, false)} - > -
-
- {isShowingMultiplePages && ( -
-
- - keyboard_arrow_down - -
- {isDisplayingCurrentPageError ? ( - - - warning - + + +
+ handleSubmit(e, false)} + > +
+
+ {isShowingMultiplePages && ( +
+
+ + keyboard_arrow_down - ) : ( - - {currentPageNumber} +
+ {isDisplayingCurrentPageError ? ( + + + warning + + + ) : ( + + {currentPageNumber} + + )} + + {currentPage ? currentPage.label : ''} + +
+ + keyboard_arrow_down - )} - - {currentPage ? currentPage.label : ''} - +
+ +
+
+ {visiblePages.map( + (page: FormTypes.PageElement, index: number) => { + const hasErrors = checkDisplayPageError(page) + return ( +
index, + 'is-error': hasErrors, + })} + onClick={(e) => { + e.stopPropagation() + if (page.id !== currentPage.id) { + setPageId(page.id) + } + }} + > +
+ {hasErrors ? ( + + + + warning + + + + ) : ( + {index + 1} + )} +
+
+

+ {page.label} +

+
+
+ ) + }, + )} +
+
- - keyboard_arrow_down - -
+ )}
-
- {visiblePages.map( - (page: FormTypes.PageElement, index: number) => { - const hasErrors = checkDisplayPageError(page) - return ( -
index, - 'is-error': hasErrors, - })} - onClick={(e) => { - e.stopPropagation() - if (page.id !== currentPage.id) { - setPageId(page.id) - } - }} - > -
- {hasErrors ? ( - - - - warning - - - - ) : ( - {index + 1} - )} -
-
-

- {page.label} -

-
-
- ) - }, - )} -
-
-
- )} + onClick={toggleStepsNavigation} + /> -
- -
-
- - - +
+ - - - - - - {visiblePages.map( - (pageElement: FormTypes.PageElement) => ( - - ), - )} - - - - - - - - -
- - {isShowingMultiplePages && ( -
-
- -
-
- {visiblePages.map((page: FormTypes.PageElement, index) => ( -
index, - 'has-background-danger': - currentPage.id !== page.id && - checkDisplayPageError(page), - })} - /> - ))} -
-
- + + + + {visiblePages.map( + (pageElement: FormTypes.PageElement) => ( + + ), + )} + + + + + + +
+ + {isShowingMultiplePages && ( +
+
+ +
+
+ {visiblePages.map( + (page: FormTypes.PageElement, index) => ( +
index, + 'has-background-danger': + currentPage.id !== page.id && + checkDisplayPageError(page), + })} + /> + ), + )} +
+
+ +
+
+ )}
- )} -
- {!isReadOnly && ( -
- {onSaveDraft && !isInfoPage && ( - - )} - - {!isInfoPage && ( - - )} - {isLastVisiblePage && ( - + {!isReadOnly && ( +
+ {onSaveDraft && !isInfoPage && ( + + )} + + {!isInfoPage && ( + + )} + {isLastVisiblePage && ( + + )} +
)}
+ + + {!isReadOnly && ( + + + + {onSaveDraft && ( + + )} + + + + + } + > +

+ You have unsaved changes, are you sure you want discard + them? +

+
+ + + + + + } + > +

+ Your attachments are still uploading, do you want to wait + for the uploads to complete or continue using the app? If + you click continue the attachments will upload in the + background. Do not close the app until the upload has been + completed. +

+
+ + + {onSaveDraft && ( + + )} + + + + + } + > +

+ You cannot submit this form while offline, please try again + when connectivity is restored. + {onSaveDraft && ( + + {' '} + Alternatively, click the{' '} + {buttons?.saveDraft?.label || 'Save Draft'}{' '} + button below to come back to this later. + + )} +

+ + wifi_off + +
+
)}
- - - {!isReadOnly && ( - - - - {onSaveDraft && ( - - )} - - - - - } - > -

- You have unsaved changes, are you sure you want discard them? -

-
- - - - - - } - > -

- Your attachments are still uploading, do you want to wait for - the uploads to complete or continue using the app? If you click - continue the attachments will upload in the background. Do not - close the app until the upload has been completed. -

-
- - - {onSaveDraft && ( - - )} - - - - - } - > -

- You cannot submit this form while offline, please try again when - connectivity is restored. - {onSaveDraft && ( - - {' '} - Alternatively, click the{' '} - {buttons?.saveDraft?.label || 'Save Draft'} button - below to come back to this later. - - )} -

- - wifi_off - -
-
- )} -
+ + ) } diff --git a/src/components/renderer/OneBlinkFormElements.tsx b/src/components/renderer/OneBlinkFormElements.tsx index f6a23cbb..a33857b1 100644 --- a/src/components/renderer/OneBlinkFormElements.tsx +++ b/src/components/renderer/OneBlinkFormElements.tsx @@ -63,6 +63,7 @@ import { FormElementValidation, FormElementValueChangeHandler, IsDirtyProps, + UpdateFormElementsHandler, } from '../../types/form' import { attachmentsService } from '@oneblink/apps' @@ -74,6 +75,7 @@ export type Props = { displayValidationMessages: boolean onChange: FormElementValueChangeHandler onLookup: FormElementLookupHandler + onUpdateFormElements: UpdateFormElementsHandler // Props passed by repeatable sets isEven?: boolean idPrefix: string @@ -92,6 +94,7 @@ interface FormElementSwitchProps extends IsDirtyProps { isEven: boolean | undefined onChange: FormElementValueChangeHandler onLookup: FormElementLookupHandler + onUpdateFormElements: UpdateFormElementsHandler } function OneBlinkFormElements({ @@ -104,6 +107,7 @@ function OneBlinkFormElements({ formElementsConditionallyShown, onChange, onLookup, + onUpdateFormElements, model, parentElement, }: Props) { @@ -136,6 +140,7 @@ function OneBlinkFormElements({ formElementsValidation={formElementsValidation} onChange={onChange} onLookup={onLookup} + onUpdateFormElements={onUpdateFormElements} model={model} parentElement={parentElement} /> @@ -165,6 +170,7 @@ function OneBlinkFormElements({ formElementValidation={formElementsValidation?.[element.name]} onChange={onChange} onLookup={onLookup} + onUpdateFormElements={onUpdateFormElements} /> ) })} @@ -223,6 +229,7 @@ const FormElementSwitch = React.memo(function OneBlinkFormElement({ id, onChange, onLookup, + onUpdateFormElements, isDirty, setIsDirty, }: FormElementSwitchProps & IsDirtyProps) { @@ -436,6 +443,7 @@ const FormElementSwitch = React.memo(function OneBlinkFormElement({ validationMessage={validationMessage} displayValidationMessage={displayValidationMessage} conditionallyShownOptionsElement={conditionallyShownOptionsElement} + onUpdateFormElements={onUpdateFormElements} {...dirtyProps} /> @@ -460,6 +468,7 @@ const FormElementSwitch = React.memo(function OneBlinkFormElement({ validationMessage={validationMessage} displayValidationMessage={displayValidationMessage} conditionallyShownOptionsElement={conditionallyShownOptionsElement} + onUpdateFormElements={onUpdateFormElements} {...dirtyProps} /> @@ -484,6 +493,7 @@ const FormElementSwitch = React.memo(function OneBlinkFormElement({ validationMessage={validationMessage} displayValidationMessage={displayValidationMessage} conditionallyShownOptionsElement={conditionallyShownOptionsElement} + onUpdateFormElements={onUpdateFormElements} {...dirtyProps} /> @@ -540,6 +550,7 @@ const FormElementSwitch = React.memo(function OneBlinkFormElement({ formElementConditionallyShown={formElementConditionallyShown} formElementValidation={formElementValidation} displayValidationMessage={displayValidationMessage} + onUpdateFormElements={onUpdateFormElements} {...dirtyProps} /> ) @@ -600,6 +611,7 @@ const FormElementSwitch = React.memo(function OneBlinkFormElement({ validationMessage={validationMessage} displayValidationMessage={displayValidationMessage} conditionallyShownOptionsElement={conditionallyShownOptionsElement} + onUpdateFormElements={onUpdateFormElements} {...dirtyProps} /> @@ -650,6 +662,7 @@ const FormElementSwitch = React.memo(function OneBlinkFormElement({ displayValidationMessages={displayValidationMessage} formElementValidation={formElementValidation} formElementConditionallyShown={formElementConditionallyShown} + onUpdateFormElements={onUpdateFormElements} /> ) } @@ -773,6 +786,7 @@ const FormElementSwitch = React.memo(function OneBlinkFormElement({ displayValidationMessage={displayValidationMessage} conditionallyShownOptionsElement={conditionallyShownOptionsElement} isEven={isEven} + onUpdateFormElements={onUpdateFormElements} {...dirtyProps} /> @@ -796,6 +810,7 @@ const FormElementSwitch = React.memo(function OneBlinkFormElement({ displayValidationMessages={displayValidationMessage} formElementValidation={formElementValidation} formElementConditionallyShown={formElementConditionallyShown} + onUpdateFormElements={onUpdateFormElements} /> ) } @@ -890,6 +905,7 @@ const FormElementSwitch = React.memo(function OneBlinkFormElement({ displayValidationMessages={displayValidationMessage} formElementValidation={formElementValidation} formElementConditionallyShown={formElementConditionallyShown} + onUpdateFormElements={onUpdateFormElements} /> ) } diff --git a/src/components/renderer/PageFormElements.tsx b/src/components/renderer/PageFormElements.tsx index 3f520e86..c134124d 100644 --- a/src/components/renderer/PageFormElements.tsx +++ b/src/components/renderer/PageFormElements.tsx @@ -9,6 +9,7 @@ import { FormElementsValidation, FormElementValueChangeHandler, SetFormSubmission, + UpdateFormElementsHandler, } from '../../types/form' import { IsPageVisibleProvider } from '../../hooks/useIsPageVisible' import { FlatpickrGuidProvider } from '../../hooks/useFlatpickrGuid' @@ -80,6 +81,44 @@ function PageFormElements({ const form = useFormDefinition() + const handleUpdateFormElements = React.useCallback( + (updateFormElements) => { + setFormSubmission((currentFormSubmission) => { + const definition = { + ...currentFormSubmission.definition, + } + + if (pageElement.id === formId.toString()) { + definition.elements = updateFormElements( + currentFormSubmission.definition.elements, + ) + } else { + definition.elements = currentFormSubmission.definition.elements.map( + (formElement) => { + if ( + formElement.id === pageElement.id && + formElement.type === 'page' + ) { + return { + ...formElement, + elements: updateFormElements(formElement.elements), + } + } else { + return formElement + } + }, + ) + } + + return { + ...currentFormSubmission, + definition, + } + }) + }, + [formId, pageElement.id, setFormSubmission], + ) + return ( @@ -99,6 +138,7 @@ function PageFormElements({ elements={pageElement.elements} onChange={onChange} onLookup={handleLookup} + onUpdateFormElements={handleUpdateFormElements} idPrefix="" />
diff --git a/src/form-elements/FormElementAutocomplete.tsx b/src/form-elements/FormElementAutocomplete.tsx index 6ef3c8c1..6d848ea3 100644 --- a/src/form-elements/FormElementAutocomplete.tsx +++ b/src/form-elements/FormElementAutocomplete.tsx @@ -11,6 +11,7 @@ import { FormElementValueChangeHandler, FormElementConditionallyShownElement, IsDirtyProps, + UpdateFormElementsHandler, } from '../types/form' type _BaseProps = { @@ -19,6 +20,7 @@ type _BaseProps = { value: unknown | undefined displayValidationMessage: boolean validationMessage: string | undefined + onUpdateFormElements: UpdateFormElementsHandler } & IsDirtyProps type _AutocompleteChangeHandlerProps = _BaseProps & { @@ -52,6 +54,7 @@ const AutocompleteFilter = React.memo(function AutocompleteFilter({ conditionallyShownOptionsElement, validationMessage, displayValidationMessage, + onUpdateFormElements, isDirty, setIsDirty, }: AutocompleteFilterProps) { @@ -83,6 +86,7 @@ const AutocompleteFilter = React.memo(function AutocompleteFilter({ onChange: handleChange, conditionallyShownOptionsElement, onFilter, + onUpdateFormElements, }) const handleSearch = React.useCallback(async () => { diff --git a/src/form-elements/FormElementCheckBoxes.tsx b/src/form-elements/FormElementCheckBoxes.tsx index 653c5464..8305b3df 100644 --- a/src/form-elements/FormElementCheckBoxes.tsx +++ b/src/form-elements/FormElementCheckBoxes.tsx @@ -13,6 +13,7 @@ import { FormElementValueChangeHandler, FormElementConditionallyShownElement, IsDirtyProps, + UpdateFormElementsHandler, } from '../types/form' type Props = { @@ -25,6 +26,7 @@ type Props = { conditionallyShownOptionsElement: | FormElementConditionallyShownElement | undefined + onUpdateFormElements: UpdateFormElementsHandler } & IsDirtyProps function FormElementCheckboxes({ @@ -35,6 +37,7 @@ function FormElementCheckboxes({ validationMessage, displayValidationMessage, conditionallyShownOptionsElement, + onUpdateFormElements, isDirty, setIsDirty, }: Props) { @@ -92,6 +95,7 @@ function FormElementCheckboxes({ value, onChange, conditionallyShownOptionsElement, + onUpdateFormElements, }) return ( diff --git a/src/form-elements/FormElementCompliance.tsx b/src/form-elements/FormElementCompliance.tsx index 66606d33..c61621b8 100644 --- a/src/form-elements/FormElementCompliance.tsx +++ b/src/form-elements/FormElementCompliance.tsx @@ -14,6 +14,7 @@ import { FormElementValueChangeHandler, FormElementConditionallyShownElement, IsDirtyProps, + UpdateFormElementsHandler, } from '../types/form' import { attachmentsService } from '@oneblink/apps' @@ -28,6 +29,7 @@ interface Props extends IsDirtyProps { | FormElementConditionallyShownElement | undefined isEven?: boolean + onUpdateFormElements: UpdateFormElementsHandler } export interface Value { @@ -53,6 +55,7 @@ function FormElementCompliance({ validationMessage, displayValidationMessage, isEven, + onUpdateFormElements, isDirty, setIsDirty, }: Props) { @@ -168,6 +171,7 @@ function FormElementCompliance({ value: typedValue?.value, onChange: handleValueChange, conditionallyShownOptionsElement, + onUpdateFormElements, }) return ( diff --git a/src/form-elements/FormElementForm.tsx b/src/form-elements/FormElementForm.tsx index f41d1c5e..73c2e016 100644 --- a/src/form-elements/FormElementForm.tsx +++ b/src/form-elements/FormElementForm.tsx @@ -6,6 +6,7 @@ import { FormElementLookupHandler, FormElementValidation, FormElementValueChangeHandler, + UpdateFormElementsHandler, } from '../types/form' export type Props = { @@ -20,6 +21,7 @@ export type Props = { formElementValidation: FormElementValidation | undefined displayValidationMessages: boolean formElementConditionallyShown: FormElementConditionallyShown | undefined + onUpdateFormElements: UpdateFormElementsHandler } function FormElementForm({ @@ -32,6 +34,7 @@ function FormElementForm({ formElementConditionallyShown, onChange, onLookup, + onUpdateFormElements, }: Props) { const handleNestedChange = React.useCallback( (nestedElement: FormTypes.FormElement, nestedElementValue: unknown) => { @@ -111,6 +114,28 @@ function FormElementForm({ } }, [element.elements]) + const handleUpdateNestedFormElements = + React.useCallback( + (setter) => { + onUpdateFormElements((formElements) => { + return formElements.map((formElement) => { + if ( + formElement.id === element.id && + formElement.type === 'form' && + Array.isArray(formElement.elements) + ) { + return { + ...formElement, + elements: setter(formElement.elements), + } + } + return formElement + }) + }) + }, + [element.id, onUpdateFormElements], + ) + return ( ) } diff --git a/src/form-elements/FormElementRadio.tsx b/src/form-elements/FormElementRadio.tsx index 485e5509..341fc6a5 100644 --- a/src/form-elements/FormElementRadio.tsx +++ b/src/form-elements/FormElementRadio.tsx @@ -11,6 +11,7 @@ import { FormElementValueChangeHandler, FormElementConditionallyShownElement, IsDirtyProps, + UpdateFormElementsHandler, } from '../types/form' type Props = { @@ -23,6 +24,7 @@ type Props = { conditionallyShownOptionsElement: | FormElementConditionallyShownElement | undefined + onUpdateFormElements: UpdateFormElementsHandler } & IsDirtyProps function FormElementRadio({ @@ -33,6 +35,7 @@ function FormElementRadio({ conditionallyShownOptionsElement, validationMessage, displayValidationMessage, + onUpdateFormElements, isDirty, setIsDirty, }: Props) { @@ -41,6 +44,7 @@ function FormElementRadio({ value, onChange, conditionallyShownOptionsElement, + onUpdateFormElements, }) return ( diff --git a/src/form-elements/FormElementRepeatableSet.tsx b/src/form-elements/FormElementRepeatableSet.tsx index 4e584234..8f11f14b 100644 --- a/src/form-elements/FormElementRepeatableSet.tsx +++ b/src/form-elements/FormElementRepeatableSet.tsx @@ -15,6 +15,7 @@ import { FormElementValidation, FormElementValueChangeHandler, IsDirtyProps, + UpdateFormElementsHandler, } from '../types/form' import useFormElementRepeatableSetEntries from '../hooks/useFormElementRepeatableSetEntries' @@ -31,6 +32,7 @@ type Props = { formElementConditionallyShown: FormElementConditionallyShown | undefined formElementValidation: FormElementValidation | undefined displayValidationMessage: boolean + onUpdateFormElements: UpdateFormElementsHandler } & IsDirtyProps const RepeatableSetIndexContext = React.createContext(0) @@ -54,6 +56,7 @@ function FormElementRepeatableSet({ formElementConditionallyShown, onChange, onLookup, + onUpdateFormElements, isDirty, setIsDirty, }: Props) { @@ -166,6 +169,7 @@ function FormElementRepeatableSet({ repeatableSetValidation.entries[index.toString()] } displayValidationMessages={displayValidationMessage} + onUpdateFormElements={onUpdateFormElements} /> ) })} @@ -217,6 +221,7 @@ type RepeatableSetEntryProps = { ) => unknown onLookup: FormElementLookupHandler onRemove: (index: number) => unknown + onUpdateFormElements: UpdateFormElementsHandler } const RepeatableSetEntry = React.memo( @@ -233,6 +238,7 @@ const RepeatableSetEntry = React.memo( onChange, onLookup, onRemove, + onUpdateFormElements, }: RepeatableSetEntryProps) { const [isConfirmingRemove, confirmRemove, cancelRemove] = useBooleanState(false) @@ -296,6 +302,27 @@ const RepeatableSetEntry = React.memo( invalidClassName: 'ob-repeatable-set__invalid', }) + const handleUpdateNestedFormElements = + React.useCallback( + (setter) => { + onUpdateFormElements((formElements) => { + return formElements.map((formElement) => { + if ( + formElement.id === element.id && + formElement.type === 'repeatableSet' + ) { + return { + ...formElement, + elements: setter(formElement.elements), + } + } + return formElement + }) + }) + }, + [element.id, onUpdateFormElements], + ) + return ( ( model={entry} parentElement={element} formElementsConditionallyShown={formElementsConditionallyShown} + onUpdateFormElements={handleUpdateNestedFormElements} />
diff --git a/src/form-elements/FormElementSection.tsx b/src/form-elements/FormElementSection.tsx index bfc285b4..2e1d6c48 100644 --- a/src/form-elements/FormElementSection.tsx +++ b/src/form-elements/FormElementSection.tsx @@ -7,7 +7,10 @@ import OneBlinkFormElements, { Props, } from '../components/renderer/OneBlinkFormElements' import { checkSectionValidity } from '../services/form-validation' -import { FormElementLookupHandler } from '../types/form' +import { + FormElementLookupHandler, + UpdateFormElementsHandler, +} from '../types/form' import { HintBelowLabel, HintTooltip, @@ -18,6 +21,7 @@ function FormElementSection({ element, onLookup, displayValidationMessages, + onUpdateFormElements, ...props }: Omit, 'elements'> & { element: FormTypes.SectionElement @@ -76,6 +80,27 @@ function FormElementSection({ [element.id, onLookup], ) + const handleUpdateNestedFormElements = + React.useCallback( + (setter) => { + onUpdateFormElements((formElements) => { + return formElements.map((formElement) => { + if ( + formElement.id === element.id && + formElement.type === 'section' + ) { + return { + ...formElement, + elements: setter(formElement.elements), + } + } + return formElement + }) + }) + }, + [element.id, onUpdateFormElements], + ) + return (
({ displayValidationMessages={displayValidationMessage} onLookup={handleLookup} elements={element.elements} + onUpdateFormElements={handleUpdateNestedFormElements} />
diff --git a/src/form-elements/FormElementSelect.tsx b/src/form-elements/FormElementSelect.tsx index 14131607..09bf6443 100644 --- a/src/form-elements/FormElementSelect.tsx +++ b/src/form-elements/FormElementSelect.tsx @@ -10,6 +10,7 @@ import { FormElementValueChangeHandler, FormElementConditionallyShownElement, IsDirtyProps, + UpdateFormElementsHandler, } from '../types/form' type Props = { @@ -22,6 +23,7 @@ type Props = { conditionallyShownOptionsElement: | FormElementConditionallyShownElement | undefined + onUpdateFormElements: UpdateFormElementsHandler } & IsDirtyProps function FormElementSelect({ @@ -32,6 +34,7 @@ function FormElementSelect({ validationMessage, displayValidationMessage, conditionallyShownOptionsElement, + onUpdateFormElements, isDirty, setIsDirty, }: Props) { @@ -40,6 +43,7 @@ function FormElementSelect({ value, onChange, conditionallyShownOptionsElement, + onUpdateFormElements, }) const selectedValuesAsArray = React.useMemo(() => { diff --git a/src/hooks/useDynamicOptionsLoaderState.ts b/src/hooks/useDynamicOptionsLoaderState.ts deleted file mode 100644 index fadb5c75..00000000 --- a/src/hooks/useDynamicOptionsLoaderState.ts +++ /dev/null @@ -1,84 +0,0 @@ -import * as React from 'react' -import _cloneDeep from 'lodash.clonedeep' -import { formElementsService } from '@oneblink/sdk-core' -import { formService, OneBlinkAppsError } from '@oneblink/apps' -import { FormTypes } from '@oneblink/types' -import { SetFormSubmission } from '../types/form' - -export default function useDynamicOptionsLoaderState( - form: FormTypes.Form, - setFormSubmission: SetFormSubmission, -) { - const [state, setState] = React.useState<{ - elementId: string - error: OneBlinkAppsError - } | null>(null) - React.useEffect(() => { - if (state) { - return - } - - const abortController = new AbortController() - - ;(async () => { - const optionsByElementId = await formService.getFormElementDynamicOptions( - form, - ) - - if (abortController.signal.aborted || !optionsByElementId.length) { - return - } - - const nonOkResponse = optionsByElementId.find( - (optionsForElementId) => optionsForElementId.type === 'ERROR', - ) - if (nonOkResponse && nonOkResponse.type === 'ERROR') { - setState({ - elementId: nonOkResponse.elementId, - error: nonOkResponse.error, - }) - return - } - - setFormSubmission((currentFormSubmission) => { - const clonedForm: FormTypes.Form = _cloneDeep( - currentFormSubmission.definition, - ) - for (const optionsForElementId of optionsByElementId) { - formElementsService.forEachFormElementWithOptions( - clonedForm.elements, - (formElement) => { - if (formElement.id === optionsForElementId.elementId) { - switch (optionsForElementId.type) { - case 'OPTIONS': { - formElement.options = optionsForElementId.options - break - } - case 'SEARCH': { - if (formElement.type === 'autocomplete') { - formElement.optionsType = 'SEARCH' - formElement.searchUrl = optionsForElementId.url - formElement.searchQuerystringParameter = - optionsForElementId.searchQuerystringParameter - } - break - } - } - } - }, - ) - } - return { - ...currentFormSubmission, - definition: clonedForm, - } - }) - })() - - return () => { - abortController.abort() - } - }, [form, setFormSubmission, state]) - - return state -} diff --git a/src/hooks/useDynamicOptionsLoaderState.tsx b/src/hooks/useDynamicOptionsLoaderState.tsx new file mode 100644 index 00000000..a061d55a --- /dev/null +++ b/src/hooks/useDynamicOptionsLoaderState.tsx @@ -0,0 +1,333 @@ +import * as React from 'react' +import { formElementsService, typeCastService } from '@oneblink/sdk-core' +import { + formService, + localisationService, + OneBlinkAppsError, +} from '@oneblink/apps' +import { FormTypes, FreshdeskTypes } from '@oneblink/types' +import useLoadDataState, { LoadDataState } from './useLoadDataState' +import OneBlinkAppsErrorOriginalMessage from '../components/renderer/OneBlinkAppsErrorOriginalMessage' +import useFormDefinition from './useFormDefinition' +import { UpdateFormElementsHandler } from '../typedoc' + +type OptionsSetResult = { + formElementOptionsSet: FormTypes.FormElementOptionSet + result?: formService.FormElementOptionsSetResult +} + +export const FormElementOptionsContext = React.createContext< + OptionsSetResult[] +>([]) +export const FreshdeskFieldsStateContext = React.createContext< + LoadDataState | undefined +>(undefined) + +export function FormElementOptionsContextProvider({ + children, +}: { + children: React.ReactNode +}) { + const form = useFormDefinition() + + const hasFreshdeskFields = React.useMemo(() => { + return !!formElementsService.findFormElement( + form.elements, + (formElement) => { + const formElementWithOptions = + typeCastService.formElements.toOptionsElement(formElement) + return ( + formElementWithOptions?.optionsType === 'FRESHDESK_FIELD' && + !!formElementWithOptions.freshdeskFieldName + ) + }, + ) + }, [form.elements]) + + const loadFreshdeskFields = React.useCallback( + async (abortSignal): Promise => { + if (hasFreshdeskFields) { + return await formService.getFreshdeskFields(form.id, abortSignal) + } + return [] + }, + [form.id, hasFreshdeskFields], + ) + + const [freshdeskFieldsState] = useLoadDataState(loadFreshdeskFields) + + const loadFormElementOptionsSets = React.useCallback( + async (abortSignal) => { + const formElementOptionsSets = + await formService.getFormElementOptionsSets( + form.organisationId, + abortSignal, + ) + return formElementOptionsSets.map( + (formElementOptionsSet) => ({ + formElementOptionsSet, + }), + ) + }, + [form.organisationId], + ) + + const [optionsSetResultsState, , setOptionsSetResults] = useLoadDataState( + loadFormElementOptionsSets, + ) + + const optionsSetResults = React.useMemo(() => { + if (optionsSetResultsState.status === 'SUCCESS') { + return optionsSetResultsState.result + } + return [] + }, [optionsSetResultsState]) + + const error = React.useMemo(() => { + if (freshdeskFieldsState.status === 'ERROR') { + return freshdeskFieldsState.error instanceof OneBlinkAppsError + ? freshdeskFieldsState.error + : new OneBlinkAppsError('An unknown error has occurred', { + originalError: freshdeskFieldsState.error, + }) + } + if (optionsSetResultsState.status === 'ERROR') { + return optionsSetResultsState.error instanceof OneBlinkAppsError + ? optionsSetResultsState.error + : new OneBlinkAppsError('An unknown error has occurred', { + originalError: optionsSetResultsState.error, + }) + } + for (const optionsSetResult of optionsSetResults) { + if (optionsSetResult.result?.type === 'ERROR') { + return optionsSetResult.result.error + } + } + }, [freshdeskFieldsState, optionsSetResults, optionsSetResultsState]) + + if (error) { + return ( + <> +
+ error +

{error.title}

+

{error.message}

+

+ {localisationService.formatDatetimeLong(new Date())} +

+
+ + + + ) + } + + return ( + <> + {optionsSetResults.map((optionsSetResult) => ( + + + + ))} + + + {children} + + + + ) +} + +const LoadOptionsSet = React.memo(function LoadOptionsSet({ + form, + optionsSetResult, + setOptionsSetResults, +}: { + form: FormTypes.Form + optionsSetResult: OptionsSetResult + setOptionsSetResults: React.Dispatch> +}) { + const hasOptionsSet = React.useMemo(() => { + return !!formElementsService.findFormElement( + form.elements, + (formElement) => { + const formElementWithOptions = + typeCastService.formElements.toOptionsElement(formElement) + return ( + formElementWithOptions?.optionsType === 'DYNAMIC' && + formElementWithOptions.dynamicOptionSetId === + optionsSetResult.formElementOptionsSet.id + ) + }, + ) + }, [form.elements, optionsSetResult.formElementOptionsSet.id]) + + const setOptionsSetResult = React.useCallback( + (result: OptionsSetResult['result']) => { + setOptionsSetResults((currentOptionsSetResults) => { + return currentOptionsSetResults.map((currentOptionsSetResult) => { + if ( + currentOptionsSetResult.formElementOptionsSet.id === + optionsSetResult.formElementOptionsSet.id + ) { + return { + ...optionsSetResult, + result, + } + } else { + return currentOptionsSetResult + } + }) + }) + }, + [optionsSetResult, setOptionsSetResults], + ) + + React.useEffect(() => { + if (!hasOptionsSet || optionsSetResult.result) { + return + } + + const abortController = new AbortController() + + ;(async () => { + try { + const result = await formService.getFormElementOptionsSetOptions( + optionsSetResult.formElementOptionsSet, + form.formsAppEnvironmentId, + abortController.signal, + ) + if (!abortController.signal.aborted) { + setOptionsSetResult(result) + } + } catch (error) { + if (!abortController.signal.aborted) { + setOptionsSetResult({ + type: 'ERROR', + error: new OneBlinkAppsError( + error instanceof Error + ? error.message + : 'An unknown error has occurred', + { + originalError: error instanceof Error ? error : undefined, + }, + ), + }) + } + } + })() + + return () => { + abortController.abort() + } + }, [ + form.formsAppEnvironmentId, + hasOptionsSet, + optionsSetResult.formElementOptionsSet, + optionsSetResult.result, + setOptionsSetResult, + ]) + + return <> +}) + +export function useLoadDynamicOptionsEffect( + formElement: FormTypes.FormElementWithOptions, + onUpdateFormElements: UpdateFormElementsHandler, +) { + const form = useFormDefinition() + const optionsSetResults = React.useContext(FormElementOptionsContext) + const freshdeskFieldsState = React.useContext(FreshdeskFieldsStateContext) + + const freshdeskFieldOptionsResult = React.useMemo(() => { + if ( + freshdeskFieldsState?.status === 'SUCCESS' && + // We can stop here if the options are not coming from freshdesk + formElement.optionsType === 'FRESHDESK_FIELD' && + // If the element already has options, we don't need to set them again + !Array.isArray(formElement.options) + ) { + return formService.parseFreshdeskFieldOptions( + freshdeskFieldsState.result, + formElement, + ) + } + }, [formElement, freshdeskFieldsState]) + + const optionsSetResult = React.useMemo(() => { + if ( + // We can stop here if the options are not dynamic + formElement.optionsType !== 'DYNAMIC' || + // If the element already has options, we don't need to set them again + Array.isArray(formElement.options) + ) { + return + } + return optionsSetResults.find( + (optionsSetResult) => + optionsSetResult.formElementOptionsSet.id === + formElement.dynamicOptionSetId && optionsSetResult.result, + ) + }, [ + formElement.dynamicOptionSetId, + formElement.options, + formElement.optionsType, + optionsSetResults, + ]) + + React.useEffect(() => { + if (!optionsSetResult && !freshdeskFieldOptionsResult) { + return + } + + onUpdateFormElements((formElements) => { + return formElements.map((existingFormElement) => { + if (existingFormElement.id === formElement.id) { + switch (optionsSetResult?.result?.type) { + case 'SEARCH': { + if (formElement.type === 'autocomplete') { + return { + ...existingFormElement, + optionsType: 'SEARCH', + searchUrl: optionsSetResult.result.url, + searchQuerystringParameter: + optionsSetResult.result.searchQuerystringParameter, + } + } + break + } + case 'OPTIONS': { + return formService.parseFormElementOptions( + form, + formElement, + optionsSetResult.result.options, + ) + } + } + + if (freshdeskFieldOptionsResult) { + return { + ...existingFormElement, + options: + freshdeskFieldOptionsResult.type === 'OPTIONS' + ? freshdeskFieldOptionsResult.options + : [], + } + } + } + + return existingFormElement + }) + }) + }, [ + form, + formElement, + freshdeskFieldOptionsResult, + onUpdateFormElements, + optionsSetResult, + ]) +} diff --git a/src/hooks/useFormElementOptions.ts b/src/hooks/useFormElementOptions.ts index 0428c90f..32230be6 100644 --- a/src/hooks/useFormElementOptions.ts +++ b/src/hooks/useFormElementOptions.ts @@ -3,7 +3,9 @@ import * as React from 'react' import { FormElementValueChangeHandler, FormElementConditionallyShownElement, + UpdateFormElementsHandler, } from '../types/form' +import { useLoadDynamicOptionsEffect } from './useDynamicOptionsLoaderState' export default function useFormElementOptions({ element, @@ -11,6 +13,7 @@ export default function useFormElementOptions({ onChange, conditionallyShownOptionsElement, onFilter, + onUpdateFormElements, }: { element: FormTypes.FormElementWithOptions value: unknown | undefined @@ -19,6 +22,7 @@ export default function useFormElementOptions({ | FormElementConditionallyShownElement | undefined onFilter?: (choiceElementOption: FormTypes.ChoiceElementOption) => boolean + onUpdateFormElements: UpdateFormElementsHandler }) { const conditionallyShownOptions = conditionallyShownOptionsElement?.options //options that are shown due to conditional logic @@ -85,5 +89,7 @@ export default function useFormElementOptions({ conditionallyShownOptionsElement?.dependencyIsLoading, ]) + useLoadDynamicOptionsEffect(element, onUpdateFormElements) + return filteredOptions } diff --git a/src/services/form-validation.ts b/src/services/form-validation.ts index 1af469fb..a871e897 100644 --- a/src/services/form-validation.ts +++ b/src/services/form-validation.ts @@ -273,7 +273,6 @@ export function generateValidationSchema( case 'calculation': case 'image': case 'html': - case 'infoPage': case 'heading': { return partialSchema } @@ -668,6 +667,7 @@ export function generateValidationSchema( }, } } + case 'infoPage': case 'form': { if (formElement.elements) { return { diff --git a/src/types/form.ts b/src/types/form.ts index a7705572..18c1747d 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -45,6 +45,9 @@ export type FormElementLookupHandler = ( elements: FormTypes.FormElement[] }, ) => void +export type UpdateFormElementsHandler = ( + setter: (element: FormTypes.FormElement[]) => FormTypes.FormElement[], +) => void export type SetFormSubmission = React.Dispatch< React.SetStateAction<{