Skip to content

Commit 8efe9b2

Browse files
committed
feat: enhance form submission handling to prevent duplicate submits and improve async support
1 parent 29f0d30 commit 8efe9b2

5 files changed

Lines changed: 96 additions & 13 deletions

File tree

apps/sensenet/src/components/editor/json-editor.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ export const JsonEditor: React.FunctionComponent<TextEditorProps> = (props) => {
2424
const logger = useLogger('JSONEditor')
2525
const [error, setError] = useState<Error | undefined>()
2626

27-
const saveContent = () => {
27+
const saveContent = async () => {
2828
try {
29-
props.saveContent(JSON.parse(textValue))
29+
await props.saveContent(JSON.parse(textValue))
3030
logger.information({
3131
message: localization.textEditor.saveSuccessNotification.replace(
3232
'{0}',
@@ -41,6 +41,7 @@ export const JsonEditor: React.FunctionComponent<TextEditorProps> = (props) => {
4141
},
4242
},
4343
})
44+
setSavedTextValue(textValue)
4445
} catch (err) {
4546
setHasChanges(true)
4647
logger.error({

apps/sensenet/src/components/editor/sn-monaco-editor.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Button, createStyles, makeStyles, useTheme } from '@material-ui/core'
22
import { clsx } from 'clsx'
3-
import React, { lazy, useContext, useRef } from 'react'
3+
import React, { lazy, useContext, useEffect, useRef, useState } from 'react'
44
import { Prompt, useHistory } from 'react-router'
55
import { PATHS } from '../../application-paths'
66
import { ResponsiveContext } from '../../context'
@@ -66,7 +66,7 @@ export interface SnMonacoEditorProps {
6666
savedTextValue: string
6767
hasChanges: boolean
6868
uri: import('react-monaco-editor').monaco.Uri
69-
handleSubmit: Function
69+
handleSubmit: () => void | Promise<unknown>
7070
renderTitle: () => JSX.Element
7171
additionalButtons?: JSX.Element
7272
handleCancel?: () => void
@@ -81,6 +81,34 @@ export const SnMonacoEditor: React.FunctionComponent<SnMonacoEditorProps> = (pro
8181
const classes = useStyles()
8282
const globalClasses = useGlobalStyles()
8383
const formSubmitButton = useRef<HTMLButtonElement>(null)
84+
const isMountedRef = useRef(true)
85+
const isSubmittingRef = useRef(false)
86+
const [isSubmitting, setIsSubmitting] = useState(false)
87+
88+
const handleSubmit = async (ev: React.FormEvent<HTMLFormElement>) => {
89+
ev.preventDefault()
90+
if (isSubmittingRef.current) {
91+
return
92+
}
93+
94+
isSubmittingRef.current = true
95+
setIsSubmitting(true)
96+
97+
try {
98+
await props.handleSubmit()
99+
} finally {
100+
isSubmittingRef.current = false
101+
if (isMountedRef.current) {
102+
setIsSubmitting(false)
103+
}
104+
}
105+
}
106+
107+
useEffect(() => {
108+
return () => {
109+
isMountedRef.current = false
110+
}
111+
}, [])
84112

85113
const renderPresets = () => {
86114
if (props.preset?.includes(PATHS.contentTypes.snPath)) {
@@ -98,10 +126,7 @@ export const SnMonacoEditor: React.FunctionComponent<SnMonacoEditorProps> = (pro
98126
<div data-test="editor-wrapper" className={classes.editorWrapper}>
99127
<form
100128
className={classes.form}
101-
onSubmit={(ev) => {
102-
ev.preventDefault()
103-
props.handleSubmit()
104-
}}
129+
onSubmit={handleSubmit}
105130
onKeyDown={async (ev) => {
106131
if (ev.key.toLowerCase() === 's' && ev.ctrlKey) {
107132
try {
@@ -135,11 +160,12 @@ export const SnMonacoEditor: React.FunctionComponent<SnMonacoEditorProps> = (pro
135160
<Button
136161
data-test="monaco-editor-submit"
137162
aria-label={localization.forms.submit}
163+
aria-busy={isSubmitting}
138164
variant="contained"
139165
color="primary"
140166
type="submit"
141167
ref={formSubmitButton}
142-
disabled={!props.hasChanges}>
168+
disabled={!props.hasChanges || isSubmitting}>
143169
{localization.forms.submit}
144170
</Button>
145171
</div>

docker-compose.dev.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ services:
1818
- sensenet-app-node-modules:/app/apps/sensenet/node_modules
1919
command: sh -c "yarn build && yarn snapp start"
2020
healthcheck:
21-
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4052/" ]
21+
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/" ]
2222
interval: 30s
2323
timeout: 3s
2424
start_period: 120s

packages/sn-controls-react/src/viewcontrols/edit-view.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface EditViewProps {
2323
actionName?: ActionName
2424
content?: GenericContent
2525
contentTypeName: string
26-
onSubmit?: (content: Partial<GenericContent>, contentTypeName?: string) => void
26+
onSubmit?: (content: Partial<GenericContent>, contentTypeName?: string) => void | Promise<unknown>
2727
renderIcon?: (name: string) => ReactElement
2828
renderTitle?: () => ReactElement
2929
handleCancel?: () => void
@@ -105,7 +105,10 @@ export const EditView: React.FC<EditViewProps> = (props) => {
105105
const [advancedFields, setAdvancedFields] = useState<AdvancedFieldGroup[]>([])
106106
const [advancedFieldStateGroup, setAdvancedFieldStateGroup] = useState<Array<{ key: string; isOpened: boolean }>>([])
107107
const contentRef = useRef({})
108+
const isMountedRef = useRef(true)
109+
const isSubmittingRef = useRef(false)
108110
const [content, setContent] = useState(contentRef.current)
111+
const [isSubmitting, setIsSubmitting] = useState(false)
109112
contentRef.current = content
110113
const classes = useStyles(props)
111114
const repository = useRepository()
@@ -114,9 +117,23 @@ export const EditView: React.FC<EditViewProps> = (props) => {
114117

115118
let isAutofocusSet = false
116119

117-
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
120+
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
118121
event.preventDefault()
119-
props.onSubmit?.(content, schema.schema.ContentTypeName)
122+
if (isSubmittingRef.current) {
123+
return
124+
}
125+
126+
isSubmittingRef.current = true
127+
setIsSubmitting(true)
128+
129+
try {
130+
await props.onSubmit?.(content, schema.schema.ContentTypeName)
131+
} finally {
132+
isSubmittingRef.current = false
133+
if (isMountedRef.current) {
134+
setIsSubmitting(false)
135+
}
136+
}
120137
}
121138

122139
const handleInputChange = (field: string, value: unknown) => {
@@ -130,6 +147,12 @@ export const EditView: React.FC<EditViewProps> = (props) => {
130147
return () => schemaObservable.dispose()
131148
}, [repository.schemas, actionName, controlMapper, props.contentTypeName])
132149

150+
useEffect(() => {
151+
return () => {
152+
isMountedRef.current = false
153+
}
154+
}, [])
155+
133156
useEffect(() => {
134157
if (actionName && schema) {
135158
const groups: AdvancedFieldGroup[] = [
@@ -280,8 +303,10 @@ export const EditView: React.FC<EditViewProps> = (props) => {
280303
</MediaQuery>
281304
<Button
282305
aria-label={props.localization?.submit || 'Submit'}
306+
aria-busy={isSubmitting}
283307
type="submit"
284308
data-test="submit"
309+
disabled={isSubmitting}
285310
form={`edit-form-${uniqueId}`}
286311
variant="contained"
287312
color="primary">

packages/sn-controls-react/test/edit-view.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,37 @@ describe('Edit view component', () => {
9090
wrapper.find('[component="form"]').simulate('submit', { preventDefault: jest.fn() })
9191
expect(onSubmit).toBeCalledWith({ VersioningMode: '1' }, 'GenericContent')
9292
})
93+
94+
it('should prevent duplicate submits while submit is pending', async () => {
95+
let resolveSubmit: () => void = () => undefined
96+
const onSubmit = jest.fn(
97+
() =>
98+
new Promise<void>((resolve) => {
99+
resolveSubmit = resolve
100+
}),
101+
)
102+
const wrapper = shallow(
103+
<EditView repository={testRepository} onSubmit={onSubmit} content={testFile} contentTypeName={testFile.Type} />,
104+
)
105+
const event = { preventDefault: jest.fn() }
106+
107+
act(() => {
108+
wrapper.find('[component="form"]').simulate('submit', event)
109+
wrapper.find('[component="form"]').simulate('submit', event)
110+
})
111+
wrapper.update()
112+
113+
expect(onSubmit).toBeCalledTimes(1)
114+
expect(wrapper.find('[data-test="submit"]').prop('disabled')).toBe(true)
115+
116+
await act(async () => {
117+
resolveSubmit()
118+
await Promise.resolve()
119+
})
120+
wrapper.update()
121+
122+
expect(wrapper.find('[data-test="submit"]').prop('disabled')).toBe(false)
123+
})
93124
//Advanced field tests
94125
it('Advanced field inputs in a group should be invisible by default', () => {
95126
const wrapper = mount(

0 commit comments

Comments
 (0)