diff --git a/packages/app/components/document/form-actions.tsx b/packages/app/components/document/form-actions.tsx index 9a1becc07..ecd95a896 100644 --- a/packages/app/components/document/form-actions.tsx +++ b/packages/app/components/document/form-actions.tsx @@ -1,10 +1,12 @@ +import Button from 'react-bootstrap/Button' import cloneDeep from 'lodash.clonedeep' import isEqual from 'lodash.isequal' -import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' -import Button from 'react-bootstrap/Button' -import { clearEmpties } from '../../utils/object' +import Spinner from 'react-bootstrap/Spinner' import styles from './form-actions.module.css' +import { clearEmpties } from '../../utils/object' +import { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { wait } from '../../utils/time' const DELETED_STATE = 'Deleted' const DELETE_CONFIRMATION = @@ -22,12 +24,14 @@ const FormActions = ({ onDelete = null, CustomActions = null, allowValidationErrors = false, + largeModel = false, }) => { const canSave = privileges.includes('edit') || privileges.includes('create') const router = useRouter() const [initialFormData, setInitialFormData] = useState(null) const [isDirty, setIsDirty] = useState(false) + const [isSaving, setIsSaving] = useState(false) useEffect(() => { if (!formData) return @@ -79,7 +83,12 @@ const FormActions = ({ } } - function handleSave() { + async function handleSave(largeModel: boolean) { + if (largeModel) { + // This hack is in place to handle RJSF's ajv8 validator performance issues. + await wait(500) + } + let brokenLinks = localStorage.getItem('brokenLinks') let hasBrokenLinks = brokenLinks && Object.values(JSON.parse(brokenLinks)).includes('false') @@ -90,6 +99,9 @@ const FormActions = ({ 'There are broken links in your document, are you sure you want to save?' ) ) { + // not saving because we need to address broken links + setIsSaving(false) + return } @@ -106,6 +118,9 @@ const FormActions = ({ }) if (errors.length) { + // not saving because we need to address errors + setIsSaving(false) + // errors are printed above the save button, pushing it down. scroll it back setTimeout(() => { let errorPanel = document.querySelector('.rjsf > .panel.errors') @@ -189,9 +204,24 @@ const FormActions = ({ )} diff --git a/packages/app/components/document/form.tsx b/packages/app/components/document/form.tsx index 9cb864f8c..4d2477697 100644 --- a/packages/app/components/document/form.tsx +++ b/packages/app/components/document/form.tsx @@ -1,6 +1,6 @@ +import Button from 'react-bootstrap/Button' import dynamic from 'next/dynamic' import { useState } from 'react' -import Button from 'react-bootstrap/Button' // JsonSchemaForm widgets rely heavily on global window, so we'll need to load them in separately // as the server side doesn't have a window! @@ -18,6 +18,7 @@ const Form = ({ onChange = (data: any) => {}, }) => { const [expandAll, setExpandAll] = useState(false) + const largeModel = model?.largeModel || false let layout = JSON.parse(model?.uiSchema || model?.layout || '{}') let formData = document?.doc || document || {} @@ -64,6 +65,7 @@ const Form = ({ imageUploadUrl={process.env.NEXT_PUBLIC_IMAGE_UPLOAD_URL} linkCheckerUrl="/meditor/api/validate/url-resolves" allowValidationErrors={allowValidationErrors} + largeModel={largeModel} /> ) diff --git a/packages/app/components/jsonschemaform/jsonschemaform.tsx b/packages/app/components/jsonschemaform/jsonschemaform.tsx index 5d05042d2..900847302 100644 --- a/packages/app/components/jsonschemaform/jsonschemaform.tsx +++ b/packages/app/components/jsonschemaform/jsonschemaform.tsx @@ -1,4 +1,11 @@ -import Form from '@rjsf/core' +import Form, { FormProps } from '@rjsf/core' +import type { + FormContextType, + FormValidation, + RJSFSchema, + StrictRJSFSchema, +} from '@rjsf/utils' +import validator from '@rjsf/validator-ajv8' import jp from 'jsonpath' import filter from 'lodash/filter' import isEqual from 'lodash/isEqual' @@ -7,8 +14,115 @@ import { useEffect, useRef } from 'react' import fields from './fields/' import templates from './templates/' import widgets from './widgets/' -import validator from '@rjsf/validator-ajv8' -import type { FormValidation } from '@rjsf/utils' + +class LargeModelForm< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +> extends Form { + // Set a reference to the super's onChange method as a class field to prevent intermediate value errors. + superOnChange = this.onChange + // A list of element node names that we will target for overriding default behavior. + targetNodeNames = ['INPUT'] + // A store of events that we conditionally re-propagate to RJSF. + eventStore = {} + // A store of state locks that get set on various events. + nodeShouldPropagateStore = {} + + constructor(props: FormProps) { + super(props) + } + + componentDidMount() { + this.formElement.current.addEventListener('input', this.handleInput, { + capture: true, + }) + this.formElement.current.addEventListener('change', this.handleChange, { + capture: true, + }) + this.formElement.current.addEventListener('blur', this.handleBlur, { + capture: true, + }) + + if (super.componentDidMount) { + super.componentDidMount() + } + } + + componentWillUnmount() { + this.formElement.current.removeEventListener('input', this.handleInput, { + capture: true, + }) + this.formElement.current.removeEventListener('change', this.handleChange, { + capture: true, + }) + this.formElement.current.removeEventListener('blur', this.handleBlur, { + capture: true, + }) + + if (super.componentWillUnmount) { + super.componentWillUnmount() + } + } + + static debounce(fn: Function, delay = 600) { + let timerId: NodeJS.Timeout + + return (...args: any[]) => { + globalThis.clearTimeout(timerId) + + timerId = setTimeout(() => fn(...args), delay) + } + } + + /* + * Targeting only nodes in our allowlist, we conditionally stop propagation so that the ajv8 validator does not get triggered. + * We state lock the event so that later logic does not propagate its change event. + */ + handleInput = (event: any) => { + if (this.targetNodeNames.includes(event.target.nodeName)) { + event.stopPropagation() + + this.nodeShouldPropagateStore[event.target.id] = false + } + } + + /* + * Targeting only nodes in our allowlist, we conditionally stop propagation so that the ajv8 validator does not get triggered. + * We store the event for later propagation. + */ + handleChange = (event: any) => { + if ( + this.targetNodeNames.includes(event.target.nodeName) && + !this.nodeShouldPropagateStore[event.target.id] + ) { + event.stopPropagation() + + this.eventStore[event.target.id] = event + + return + } + } + + /* + * Targeting only nodes in our allowlist, we check to make sure node's event has been stored. + * Because the ajv8 validator is slow on large schemas, we wait until the `blur` event before triggering an update. + * We mark the node as ready to propagate, re-dispatching the event. + * Debounce this handler for rapid tabbing, which triggers unnecessary state updates. + */ + handleBlur = LargeModelForm.debounce((event: any) => { + if ( + this.targetNodeNames.includes(event.target.nodeName) && + this.eventStore[event.target.id] + ) { + this.nodeShouldPropagateStore[event.target.id] = true + + this.eventStore[event.target.id].target.dispatchEvent( + this.eventStore[event.target.id] + ) + } + }) +} const JsonSchemaForm = ({ schema, @@ -18,9 +132,11 @@ const JsonSchemaForm = ({ layout, liveValidate = false, allowValidationErrors = false, - onInit = (form: any) => {}, - onChange = (event: any) => {}, + largeModel = false, + onInit = (_form: any) => {}, + onChange = (_event: any) => {}, }) => { + const FormType = largeModel ? LargeModelForm : Form const formEl: any = useRef(null) useEffect(() => { @@ -75,7 +191,7 @@ const JsonSchemaForm = ({ } return ( -
updateConcatenatedFieldValueFromFields(fields) el.onchange = () => updateConcatenatedFieldValueFromFields(fields) - el.onkeyup = () => updateConcatenatedFieldValueFromFields(fields) }) }, 500) } diff --git a/packages/app/models/types.ts b/packages/app/models/types.ts index 407b32d98..2bfe98b76 100644 --- a/packages/app/models/types.ts +++ b/packages/app/models/types.ts @@ -25,6 +25,7 @@ export interface Model { workflow?: string notificationTemplate?: string templates?: Template[] + largeModel?: boolean } export interface ModelWithWorkflow extends Omit { diff --git a/packages/app/package-lock.json b/packages/app/package-lock.json index e41ac1ce1..fdc52f284 100644 --- a/packages/app/package-lock.json +++ b/packages/app/package-lock.json @@ -12,7 +12,7 @@ "@json2csv/node": "^7.0.1", "@rjsf/core": "^5.16.1", "@rjsf/utils": "^5.16.1", - "@rjsf/validator-ajv8": "^5.16.1", + "@rjsf/validator-ajv8": "^6.0.0-alpha.0", "@yaireo/tagify": "3.8.0", "bootstrap": "^4.5.0", "brace": "^0.11.1", @@ -3421,9 +3421,9 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, "node_modules/@rjsf/validator-ajv8": { - "version": "5.22.3", - "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-5.22.3.tgz", - "integrity": "sha512-fHu+oPOckpSHMwKdPCP/h8TtcOJ4I45RxFR//cN1c+um6OtpE/0t9JkVWAtbQlNJffIrzacnJjH5NpGwssxjrA==", + "version": "6.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.0.0-alpha.0.tgz", + "integrity": "sha512-vDi7rfmdkX2QoVxM8a5C6SyvH6/Uq7kPumeYZmsMcyMpHuyyRBOTIRz8fRvImaUe5YIJlkC2uKrh742LFhopfQ==", "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^2.1.1", @@ -3434,7 +3434,7 @@ "node": ">=14" }, "peerDependencies": { - "@rjsf/utils": "^5.22.x" + "@rjsf/utils": "^5.20.x" } }, "node_modules/@rjsf/validator-ajv8/node_modules/ajv": { diff --git a/packages/app/package.json b/packages/app/package.json index 910ea55c8..19a153cf2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -18,7 +18,7 @@ "@json2csv/node": "^7.0.1", "@rjsf/core": "^5.16.1", "@rjsf/utils": "^5.16.1", - "@rjsf/validator-ajv8": "^5.16.1", + "@rjsf/validator-ajv8": "^6.0.0-alpha.0", "@yaireo/tagify": "3.8.0", "bootstrap": "^4.5.0", "brace": "^0.11.1", diff --git a/packages/app/pages/[modelName]/[documentTitle]/index.tsx b/packages/app/pages/[modelName]/[documentTitle]/index.tsx index 3deaf493b..9e3735d14 100644 --- a/packages/app/pages/[modelName]/[documentTitle]/index.tsx +++ b/packages/app/pages/[modelName]/[documentTitle]/index.tsx @@ -60,6 +60,7 @@ const EditDocumentPage = ({ const params = router.query const documentTitle = decodeURIComponent(params.documentTitle as string) const modelName = params.modelName as string + const largeModel = model?.largeModel || false const [currentVersion, setCurrentVersion] = useState(null) const [form, setForm] = useState(null) @@ -372,6 +373,7 @@ const EditDocumentPage = ({ allowValidationErrors={ model.workflow.currentNode.allowValidationErrors } + largeModel={largeModel} /> )