Skip to content

Commit 381e91c

Browse files
MrFlashAccountsomebody1234jdunkerley
authored
Update form on datalink save (#12823)
* Update form on datalink save * Reset to the latest default values. * Make forms less memoized XD * Fix dropdowns for Datalinks in Asset Properties? * :( --------- Co-authored-by: somebody1234 <ehern.lee@gmail.com> Co-authored-by: James Dunkerley <jdunkerley@users.noreply.github.com>
1 parent a3573e7 commit 381e91c

File tree

7 files changed

+123
-103
lines changed

7 files changed

+123
-103
lines changed

app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
/** @file Form component. */
22
import * as React from 'react'
33

4-
import * as textProvider from '#/providers/TextProvider'
5-
6-
import * as aria from '#/components/aria'
7-
84
import { useEventCallback } from '#/hooks/eventCallbackHooks'
95
import { forwardRef } from '#/utilities/react'
106
import * as dialog from '../Dialog'
@@ -23,8 +19,6 @@ export const Form = forwardRef(function Form<
2319
Schema extends components.TSchema,
2420
SubmitResult = void,
2521
>(props: types.FormProps<Schema, SubmitResult>, ref: React.Ref<HTMLFormElement>) {
26-
/** Input values for this form. */
27-
type FieldValues = components.FieldValues<Schema>
2822
const formId = React.useId()
2923

3024
const {
@@ -47,8 +41,6 @@ export const Form = forwardRef(function Form<
4741
...formProps
4842
} = props
4943

50-
const { getText } = textProvider.useText()
51-
5244
const dialogContext = dialog.useDialogContext()
5345

5446
const onSubmit = useEventCallback(
@@ -90,16 +82,6 @@ export const Form = forwardRef(function Form<
9082
gap,
9183
})
9284

93-
const { formState } = innerForm
94-
95-
// eslint-disable-next-line no-restricted-syntax
96-
const errors = Object.fromEntries(
97-
Object.entries(formState.errors).map(([key, error]) => {
98-
const message = error?.message ?? getText('arbitraryFormErrorMessage')
99-
return [key, message]
100-
}),
101-
) as Record<keyof FieldValues, string>
102-
10385
return (
10486
<form
10587
{...formProps}
@@ -111,11 +93,9 @@ export const Form = forwardRef(function Form<
11193
data-testid={testId}
11294
onSubmit={innerForm.submit}
11395
>
114-
<aria.FormValidationContext.Provider value={errors}>
115-
<components.FormProvider form={innerForm}>
116-
{typeof children === 'function' ? children({ ...innerForm, form: innerForm }) : children}
117-
</components.FormProvider>
118-
</aria.FormValidationContext.Provider>
96+
<components.FormProvider form={innerForm}>
97+
{typeof children === 'function' ? children({ ...innerForm, form: innerForm }) : children}
98+
</components.FormProvider>
11999
</form>
120100
)
121101
}) as unknown as (<Schema extends components.TSchema, SubmitResult = void>(
@@ -142,6 +122,7 @@ export const Form = forwardRef(function Form<
142122
useFieldRegister: typeof components.useFieldRegister
143123
useFieldState: typeof components.useFieldState
144124
useFormError: typeof components.useFormError
125+
useFormState: typeof components.useFormState
145126
/* eslint-enable @typescript-eslint/naming-convention */
146127
}
147128

@@ -165,3 +146,4 @@ Form.FIELD_STYLES = components.FIELD_STYLES
165146
Form.useFieldRegister = components.useFieldRegister
166147
Form.useFieldState = components.useFieldState
167148
Form.useFormError = components.useFormError
149+
Form.useFormState = components.useFormState

app/gui/src/dashboard/components/AriaComponents/Form/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export * from './useFieldState'
1414
export * from './useForm'
1515
export * from './useFormError'
1616
export * from './useFormSchema'
17+
export * from './useFormState'

app/gui/src/dashboard/components/AriaComponents/Form/components/useForm.ts

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
6060
`,
6161
)
6262

63+
// We need to disable the eslint rules here, because we call hooks conditionally
64+
// but it's safe to do so, because we don't switch between the two types of arguments
65+
// and if we do, we throw an error.
66+
67+
/* eslint-disable react-compiler/react-compiler */
68+
/* eslint-disable react-hooks/rules-of-hooks */
6369
if ('formState' in optionsOrFormInstance) {
6470
return optionsOrFormInstance
6571
} else {
@@ -134,29 +140,32 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
134140
),
135141
})
136142

137-
const register: types.UseFormRegister<Schema> = (name, opts) => {
138-
const registered = formInstance.register(name, opts)
139-
140-
const onChange: types.UseFormRegisterReturn<Schema>['onChange'] = (value) =>
141-
registered.onChange(mapValueOnEvent(value))
142-
143-
const onBlur: types.UseFormRegisterReturn<Schema>['onBlur'] = (value) =>
144-
registered.onBlur(mapValueOnEvent(value))
145-
146-
const result: types.UseFormRegisterReturn<Schema, typeof name> = {
147-
...registered,
148-
disabled: registered.disabled ?? false,
149-
isDisabled: registered.disabled ?? false,
150-
invalid: !!formInstance.formState.errors[name],
151-
isInvalid: !!formInstance.formState.errors[name],
152-
required: registered.required ?? false,
153-
isRequired: registered.required ?? false,
154-
onChange,
155-
onBlur,
156-
}
143+
const register: types.UseFormRegister<Schema> = React.useCallback(
144+
(name, opts) => {
145+
const registered = formInstance.register(name, opts)
146+
147+
const onChange: types.UseFormRegisterReturn<Schema>['onChange'] = (value) =>
148+
registered.onChange(mapValueOnEvent(value))
149+
150+
const onBlur: types.UseFormRegisterReturn<Schema>['onBlur'] = (value) =>
151+
registered.onBlur(mapValueOnEvent(value))
152+
153+
const result: types.UseFormRegisterReturn<Schema, typeof name> = {
154+
...registered,
155+
disabled: registered.disabled ?? false,
156+
isDisabled: registered.disabled ?? false,
157+
invalid: !!formInstance.formState.errors[name],
158+
isInvalid: !!formInstance.formState.errors[name],
159+
required: registered.required ?? false,
160+
isRequired: registered.required ?? false,
161+
onChange,
162+
onBlur,
163+
}
157164

158-
return result
159-
}
165+
return result
166+
},
167+
[formInstance],
168+
)
160169

161170
// We need to disable the eslint rules here, because we call hooks conditionally
162171
// but it's safe to do so, because we don't switch between the two types of arguments
@@ -181,7 +190,7 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
181190
}
182191

183192
if (resetOnSubmit) {
184-
formInstance.reset()
193+
form.reset()
185194
}
186195

187196
return result
@@ -248,8 +257,14 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
248257
formInstance.setError('root.submit', { message: error })
249258
})
250259

260+
const reset = useEventCallback(() => {
261+
// eslint-disable-next-line no-restricted-syntax
262+
formInstance.reset(options.defaultValues as types.FieldValues<Schema>)
263+
})
264+
251265
const form: types.UseFormReturn<Schema> = {
252266
...formInstance,
267+
reset,
253268
submit,
254269
// @ts-expect-error Our `UseFormRegister<Schema>` is the same as `react-hook-form`'s,
255270
// just with an added constraint.
@@ -264,8 +279,8 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
264279

265280
return form
266281
}
267-
/* eslint-enable react-compiler/react-compiler */
268282
/* eslint-enable react-hooks/rules-of-hooks */
283+
/* eslint-enable react-compiler/react-compiler */
269284
}
270285

271286
/** Get the type of arguments passed to the useForm hook */
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @file
3+
*
4+
* A hook for subscribing to the state of a form.
5+
*/
6+
import * as reactHookForm from 'react-hook-form'
7+
import { useFormContext } from './FormProvider'
8+
import type { FieldPath, FormWithValueValidation, TSchema } from './types'
9+
10+
/** Options for {@link useFormState} hook. */
11+
export interface UseFormStateOptions<
12+
BaseValueType,
13+
Schema extends TSchema,
14+
TFieldName extends FieldPath<Schema, Constraint>,
15+
Constraint,
16+
> extends FormWithValueValidation<BaseValueType, Schema, TFieldName, Constraint> {
17+
/** Whether to subscribe to the state of the form. */
18+
readonly isDisabled?: boolean | undefined
19+
}
20+
21+
/** A hook that subscribes to the state of a form. */
22+
export function useFormState<
23+
BaseValueType,
24+
Schema extends TSchema,
25+
TFieldName extends FieldPath<Schema, Constraint>,
26+
Constraint,
27+
>(options: UseFormStateOptions<BaseValueType, Schema, TFieldName, Constraint>) {
28+
const { isDisabled = false } = options
29+
const form = useFormContext(options.form)
30+
31+
return reactHookForm.useFormState({
32+
control: form.control,
33+
disabled: isDisabled,
34+
})
35+
}

app/gui/src/dashboard/layouts/AssetPanel/components/AssetProperties.tsx

Lines changed: 32 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import { validateDatalink } from '#/data/datalinkValidator'
2020
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
2121
import { useEventCallback } from '#/hooks/eventCallbackHooks'
2222
import { useSpotlight } from '#/hooks/spotlightHooks'
23-
import { useSyncRef } from '#/hooks/syncRefHooks'
2423
import { assetPanelStore, useSetAssetPanelProps } from '#/layouts/AssetPanel/'
2524
import type { Category } from '#/layouts/CategorySwitcher/Category'
2625
import UpsertSecretModal from '#/modals/UpsertSecretModal'
@@ -203,28 +202,6 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
203202
resetEditDescriptionForm({ description: item.description ?? '' })
204203
}, [item.description, resetEditDescriptionForm])
205204

206-
const editDatalinkForm = Form.useForm({
207-
schema: (z) => z.object({ datalink: z.custom((x) => validateDatalink(x)) }),
208-
defaultValues: { datalink: datalinkQuery.data },
209-
onSubmit: async ({ datalink }) => {
210-
await createDatalinkMutation.mutateAsync([
211-
{
212-
// The UI to submit this form is only visible if the asset is a datalink.
213-
// eslint-disable-next-line no-restricted-syntax
214-
datalinkId: item.id as DatalinkId,
215-
name: item.title,
216-
parentDirectoryId: null,
217-
value: datalink,
218-
},
219-
])
220-
},
221-
})
222-
223-
const editDatalinkFormRef = useSyncRef(editDatalinkForm)
224-
React.useEffect(() => {
225-
editDatalinkFormRef.current.setValue('datalink', datalinkQuery.data)
226-
}, [datalinkQuery.data, editDatalinkFormRef])
227-
228205
return (
229206
<div className="flex w-full flex-col gap-8">
230207
{descriptionSpotlight.spotlightElement}
@@ -268,6 +245,7 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
268245
}
269246
</div>
270247
</div>
248+
271249
{isCloud && (
272250
<div className={styles.section()}>
273251
<Heading
@@ -484,22 +462,38 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
484462
<div className="grid place-items-center self-stretch">
485463
<StatelessSpinner size={48} state="loading-medium" />
486464
</div>
487-
: <Form form={editDatalinkForm} className="w-full">
488-
<DatalinkFormInput
489-
form={editDatalinkForm}
490-
name="datalink"
491-
readOnly={!canEditThisAsset}
492-
dropdownTitle={getText('type')}
493-
/>
494-
{canEditThisAsset && (
495-
<ButtonGroup>
496-
<Form.Submit>{getText('update')}</Form.Submit>
497-
<Form.Reset
498-
onPress={() => {
499-
editDatalinkForm.reset({ datalink: datalinkQuery.data })
500-
}}
465+
: <Form
466+
schema={(z) => z.object({ datalink: z.custom((x) => validateDatalink(x)) })}
467+
defaultValues={{ datalink: datalinkQuery.data }}
468+
onSubmit={({ datalink }) =>
469+
createDatalinkMutation.mutateAsync([
470+
{
471+
datalinkId: item.id,
472+
name: item.title,
473+
parentDirectoryId: null,
474+
value: datalink,
475+
},
476+
])
477+
}
478+
className="w-full bg-white"
479+
>
480+
{(form) => (
481+
<>
482+
<DatalinkFormInput
483+
name="datalink"
484+
readOnly={!canEditThisAsset}
485+
dropdownTitle={getText('type')}
501486
/>
502-
</ButtonGroup>
487+
488+
{canEditThisAsset && form.formState.isDirty && (
489+
<ButtonGroup>
490+
<Form.Submit>{getText('update')}</Form.Submit>
491+
<Form.Reset />
492+
</ButtonGroup>
493+
)}
494+
495+
<Form.FormError />
496+
</>
503497
)}
504498
</Form>
505499
}

app/gui/src/dashboard/layouts/Settings/FormEntry.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { ButtonGroup, Form } from '#/components/AriaComponents'
33
import { useText } from '#/providers/TextProvider'
44
import { AnimatePresence, motion } from 'framer-motion'
5-
import { useEffect, useMemo, useRef, useState } from 'react'
5+
import { useEffect, useRef, useState } from 'react'
66
import SettingsInput from './Input'
77
import type { SettingsContext, SettingsFormEntryData } from './data'
88

@@ -27,10 +27,6 @@ export function SettingsFormEntry<T extends Record<keyof T, string>>(
2727
const [initialValueString] = useState(() => JSON.stringify(value))
2828
const valueStringRef = useRef(initialValueString)
2929

30-
const schema = useMemo(
31-
() => (typeof schemaRaw === 'function' ? schemaRaw(context) : schemaRaw),
32-
[context, schemaRaw],
33-
)
3430
const isEditable = data.inputs.some((inputData) =>
3531
typeof inputData.editable === 'boolean' ?
3632
inputData.editable
@@ -39,16 +35,16 @@ export function SettingsFormEntry<T extends Record<keyof T, string>>(
3935

4036
const form = Form.useForm({
4137
// @ts-expect-error This is SAFE, as the type `T` is statically known.
42-
schema,
38+
schema: typeof schemaRaw === 'function' ? schemaRaw(context) : schemaRaw,
4339
defaultValues: value,
44-
onSubmit: async (newValue) => {
40+
onSubmit: (newValue) => {
4541
// @ts-expect-error This is SAFE, as the type `T` is statically known.
46-
await onSubmit(context, newValue)
47-
form.reset(newValue)
48-
// The form should not be reset on error.
42+
return onSubmit(context, newValue)
4943
},
5044
})
5145

46+
const { isDirty } = Form.useFormState({ form })
47+
5248
useEffect(() => {
5349
const newValueString = JSON.stringify(value)
5450

@@ -60,7 +56,7 @@ export function SettingsFormEntry<T extends Record<keyof T, string>>(
6056

6157
if (!visible) return null
6258

63-
const shouldShowSaveButton = isEditable && form.formState.isDirty
59+
const shouldShowSaveButton = isEditable && isDirty
6460

6561
return (
6662
<Form form={form}>

app/gui/src/dashboard/layouts/Settings/data.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,20 +69,17 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
6969
settingsFormEntryData({
7070
type: 'form',
7171
schema: z.object({
72-
name: z.string().regex(/.*\S.*/),
73-
email: z.string().email(),
72+
name: z.string().min(1),
73+
email: z.string().email().or(z.literal('')),
7474
timeZone: z.string().or(z.undefined()),
7575
}),
7676
getValue: (context) => ({
7777
...pick(context.user, 'name', 'email'),
78-
timeZone: context.preferredTimeZone,
78+
timeZone: context.preferredTimeZone ?? '',
7979
}),
8080
onSubmit: async (context, { name, timeZone }) => {
81-
const oldName = context.user.name
82-
if (name !== oldName) {
83-
await context.updateUser([{ username: name }])
84-
}
8581
context.setPreferredTimeZone(timeZone)
82+
await context.updateUser([{ username: name }])
8683
},
8784
inputs: [
8885
{ nameId: 'userNameSettingsInput', name: 'name' },

0 commit comments

Comments
 (0)