fix: improve error path merging from server, make sure no new or removed rows/values coming from the server are being considered outside addFieldRow

This commit is contained in:
Alessio Gravili
2024-03-27 15:55:19 -04:00
parent 08ff286f9a
commit db8e805a96
13 changed files with 115 additions and 59 deletions

View File

@@ -8,7 +8,7 @@ export type Data = {
export type Row = { export type Row = {
blockType?: string blockType?: string
collapsed?: boolean collapsed?: boolean
errorPaths?: Set<string> errorPaths?: string[]
id: string id: string
} }
@@ -19,7 +19,7 @@ export type FilterOptionsResult = {
export type FormField = { export type FormField = {
disableFormData?: boolean disableFormData?: boolean
errorMessage?: string errorMessage?: string
errorPaths?: Set<string> errorPaths?: string[]
fieldSchema?: Field fieldSchema?: Field
filterOptions?: FilterOptionsResult filterOptions?: FilterOptionsResult
initialValue: unknown initialValue: unknown

View File

@@ -70,7 +70,7 @@ export const ArrayRow: React.FC<ArrayRowProps> = ({
'0', '0',
)}` )}`
const errorCount = row.errorPaths?.size const errorCount = row.errorPaths?.length
const fieldHasErrors = errorCount > 0 && hasSubmitted const fieldHasErrors = errorCount > 0 && hasSubmitted
const classNames = [ const classNames = [

View File

@@ -181,7 +181,7 @@ export const _ArrayField: React.FC<ArrayFieldProps> = (props) => {
const hasMaxRows = maxRows && rows.length >= maxRows const hasMaxRows = maxRows && rows.length >= maxRows
const fieldErrorCount = const fieldErrorCount =
rows.reduce((total, row) => total + (row?.errorPaths?.size || 0), 0) + (valid ? 0 : 1) rows.reduce((total, row) => total + (row?.errorPaths?.length || 0), 0) + (valid ? 0 : 1)
const fieldHasErrors = submitted && fieldErrorCount > 0 const fieldHasErrors = submitted && fieldErrorCount > 0

View File

@@ -192,7 +192,7 @@ const _BlocksField: React.FC<BlocksFieldProps> = (props) => {
const hasMaxRows = maxRows && rows.length >= maxRows const hasMaxRows = maxRows && rows.length >= maxRows
const fieldErrorCount = rows.reduce((total, row) => total + (row?.errorPaths?.size || 0), 0) const fieldErrorCount = rows.reduce((total, row) => total + (row?.errorPaths?.length || 0), 0)
const fieldHasErrors = submitted && fieldErrorCount + (valid ? 0 : 1) > 0 const fieldHasErrors = submitted && fieldErrorCount + (valid ? 0 : 1) > 0
const showMinRows = rows.length < minRows || (required && rows.length === 0) const showMinRows = rows.length < minRows || (required && rows.length === 0)

View File

@@ -17,14 +17,13 @@ const ObjectId = (ObjectIdImport.default ||
export function fieldReducer(state: FormState, action: FieldAction): FormState { export function fieldReducer(state: FormState, action: FieldAction): FormState {
switch (action.type) { switch (action.type) {
case 'REPLACE_STATE': { case 'REPLACE_STATE': {
const newState = {}
if (action.optimize !== false) { if (action.optimize !== false) {
// Only update fields that have changed // Only update fields that have changed
// by comparing old value / initialValue to new // by comparing old value / initialValue to new
// .. // ..
// This is a performance enhancement for saving // This is a performance enhancement for saving
// large documents with hundreds of fields // large documents with hundreds of fields
const newState = {}
Object.entries(action.state).forEach(([path, field]) => { Object.entries(action.state).forEach(([path, field]) => {
const oldField = state[path] const oldField = state[path]
@@ -36,14 +35,12 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
newState[path] = oldField newState[path] = oldField
} }
}) })
} else { return newState
}
// If we're not optimizing, just set the state to the new state // If we're not optimizing, just set the state to the new state
return action.state return action.state
} }
return newState
}
case 'REMOVE': { case 'REMOVE': {
const newState = { ...state } const newState = { ...state }
if (newState[action.path]) delete newState[action.path] if (newState[action.path]) delete newState[action.path]

View File

@@ -5,7 +5,7 @@ import type { FormState } from 'payload/types'
import isDeepEqual from 'deep-equal' import isDeepEqual from 'deep-equal'
import { useRouter } from 'next/navigation.js' import { useRouter } from 'next/navigation.js'
import { serialize } from 'object-to-formdata' import { serialize } from 'object-to-formdata'
import { wait } from 'payload/utilities' import { deepCopyObject, wait } from 'payload/utilities'
import QueryString from 'qs' import QueryString from 'qs'
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react' import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
@@ -544,7 +544,11 @@ export const Form: React.FC<FormProps> = (props) => {
) )
if (changed) { if (changed) {
dispatchFields({ type: 'REPLACE_STATE', optimize: false, state: newState }) dispatchFields({
type: 'REPLACE_STATE',
optimize: false,
state: newState,
})
} }
} }
} }

View File

@@ -0,0 +1,38 @@
import { arraysHaveSameStrings } from '@payloadcms/ui/utilities/arraysHaveSameStrings'
export const mergeErrorPaths = (
existing?: string[],
incoming?: string[],
): {
changed: boolean
result?: string[]
} => {
if (!existing) {
return {
changed: false,
result: undefined,
}
}
const existingErrorPaths: string[] = []
const incomingErrorPaths: string[] = []
if (Array.isArray(incoming) && incoming?.length) {
incoming.forEach((path) => incomingErrorPaths.push(path))
}
if (Array.isArray(existing) && existing?.length) {
existing.forEach((path) => existingErrorPaths.push(path))
}
if (!arraysHaveSameStrings(existingErrorPaths, incomingErrorPaths)) {
return {
changed: true,
result: incomingErrorPaths,
}
}
return {
changed: false,
result: existing,
}
}

View File

@@ -1,10 +1,8 @@
import type { FormState } from 'payload/types' import type { FormState } from 'payload/types'
import isDeepEqual from 'deep-equal' import { mergeErrorPaths } from './mergeErrorPaths.js'
import { arraysHaveSameStrings } from '../../utilities/arraysHaveSameStrings.js' const serverPropsToAccept = ['passesCondition', 'valid', 'errorMessage']
const propsToCheck = ['passesCondition', 'valid', 'errorMessage']
/** /**
* Merges certain properties from the server state into the client state. These do not include values, * Merges certain properties from the server state into the client state. These do not include values,
@@ -14,51 +12,70 @@ const propsToCheck = ['passesCondition', 'valid', 'errorMessage']
* is the thing we want to keep in sync with the server (where it's calculated) on the client. * is the thing we want to keep in sync with the server (where it's calculated) on the client.
*/ */
export const mergeServerFormState = ( export const mergeServerFormState = (
oldState: FormState, existingState: FormState,
newState: FormState, incomingState: FormState,
): { changed: boolean; newState: FormState } => { ): { changed: boolean; newState: FormState } => {
let changed = false let changed = false
if (oldState) { if (existingState) {
Object.entries(newState).forEach(([path, newFieldState]) => { Object.entries(existingState).forEach(([path, newFieldState]) => {
if (!oldState[path]) return if (!incomingState[path]) return
newFieldState.initialValue = oldState[path].initialValue /**
newFieldState.value = oldState[path].value * Handle error paths
*/
const oldErrorPaths: string[] = [] const errorPathsResult = mergeErrorPaths(
const newErrorPaths: string[] = [] newFieldState.errorPaths,
incomingState[path].errorPaths as unknown as string[],
if (oldState[path].errorPaths instanceof Set) { )
oldState[path].errorPaths.forEach((path) => oldErrorPaths.push(path)) if (errorPathsResult.result) {
if (errorPathsResult.changed) {
changed = errorPathsResult.changed
}
newFieldState.errorPaths = errorPathsResult.result
} }
if ( /**
newFieldState.errorPaths && * Handle the rest which is in serverPropsToAccept
!Array.isArray(newFieldState.errorPaths) && */
typeof newFieldState.errorPaths serverPropsToAccept.forEach((prop) => {
) { if (incomingState[path]?.[prop] !== newFieldState[prop]) {
Object.values(newFieldState.errorPaths).forEach((path) => newErrorPaths.push(path))
}
if (!arraysHaveSameStrings(oldErrorPaths, newErrorPaths)) {
changed = true changed = true
if (!(prop in incomingState[path])) {
delete newFieldState[prop]
} else {
newFieldState[prop] = incomingState[path][prop]
} }
propsToCheck.forEach((prop) => {
if (newFieldState[prop] != oldState[path][prop]) {
changed = true
} }
}) })
if ( /**
Array.isArray(newFieldState.rows) && * Handle rows
!isDeepEqual(newFieldState.rows, oldState[path].rows) */
) { if (Array.isArray(newFieldState.rows) && newFieldState?.rows.length) {
let i = 0
for (const row of newFieldState.rows) {
const incomingRow = incomingState[path]?.rows?.[i]
if (incomingRow) {
const errorPathsRowResult = mergeErrorPaths(
row.errorPaths,
incomingRow.errorPaths as unknown as string[],
)
if (errorPathsRowResult.result) {
if (errorPathsResult.changed) {
changed = true changed = true
} }
incomingRow.errorPaths = errorPathsRowResult.result
}
}
i++
}
}
}) })
} }
return { changed, newState } // Conditions don't work if we don't memcopy the new state, as the object references would otherwise be the same
return { changed, newState: { ...existingState } }
} }

View File

@@ -23,7 +23,7 @@ export type AddFieldStatePromiseArgs = {
*/ */
anyParentLocalized?: boolean anyParentLocalized?: boolean
data: Data data: Data
errorPaths: Set<string> errorPaths: string[]
field: NonPresentationalField field: NonPresentationalField
/** /**
* You can use this to filter down to only `localized` fields that require translation (type: text, textarea, etc.). Another plugin might want to look for only `point` type fields to do some GIS function. With the filter function you can go in like a surgeon. * You can use this to filter down to only `localized` fields that require translation (type: text, textarea, etc.). Another plugin might want to look for only `point` type fields to do some GIS function. With the filter function you can go in like a surgeon.
@@ -74,7 +74,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id, id,
anyParentLocalized = false, anyParentLocalized = false,
data, data,
errorPaths: parentErrorPaths, errorPaths: parentErrorPaths = [],
field, field,
filter, filter,
forceFullValue = false, forceFullValue = false,
@@ -95,7 +95,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
const validate = operation === 'update' ? field.validate : undefined const validate = operation === 'update' ? field.validate : undefined
const fieldState: FormField = { const fieldState: FormField = {
errorPaths: new Set(), errorPaths: [],
fieldSchema: includeSchema ? field : undefined, fieldSchema: includeSchema ? field : undefined,
initialValue: undefined, initialValue: undefined,
passesCondition, passesCondition,
@@ -144,7 +144,9 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState.valid = false fieldState.valid = false
// TODO: this is unpredictable, need to figure out why // TODO: this is unpredictable, need to figure out why
// It will sometimes lead to inconsistencies across re-renders // It will sometimes lead to inconsistencies across re-renders
parentErrorPaths.add(`${path}${field.name}`) if (!parentErrorPaths.includes(`${path}${field.name}`)) {
parentErrorPaths.push(`${path}${field.name}`)
}
} else { } else {
fieldState.valid = true fieldState.valid = true
} }

View File

@@ -40,7 +40,7 @@ export const buildStateFromSchema = async (args: Args): Promise<FormState> => {
await iterateFields({ await iterateFields({
id, id,
data: fullData, data: fullData,
errorPaths: new Set(), errorPaths: [],
fields: fieldSchema, fields: fieldSchema,
fullData, fullData,
operation, operation,

View File

@@ -12,7 +12,7 @@ type Args = {
*/ */
anyParentLocalized?: boolean anyParentLocalized?: boolean
data: Data data: Data
errorPaths: Set<string> errorPaths: string[]
fields: FieldSchema[] fields: FieldSchema[]
filter?: (args: AddFieldStatePromiseArgs) => boolean filter?: (args: AddFieldStatePromiseArgs) => boolean
/** /**

View File

@@ -9,7 +9,6 @@ import type { ReadOnlyCollection, RestrictedVersion } from './payload-types.js'
import { import {
closeNav, closeNav,
delayNetwork,
exactText, exactText,
initPageConsoleErrorCatch, initPageConsoleErrorCatch,
openDocControls, openDocControls,
@@ -60,7 +59,6 @@ describe('access control', () => {
const context = await browser.newContext() const context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
initPageConsoleErrorCatch(page) initPageConsoleErrorCatch(page)
await delayNetwork({ context, page, delay: 'Slow 4G' })
}) })
test('field without read access should not show', async () => { test('field without read access should not show', async () => {

View File

@@ -32,7 +32,7 @@ async function navigateToLexicalFields() {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'lexical-fields') const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'lexical-fields')
await page.goto(url.list) await page.goto(url.list)
const linkToDoc = page.locator('tbody tr:first-child .cell-title a').first() const linkToDoc = page.locator('tbody tr:first-child .cell-title a').first()
await expect(() => expect(linkToDoc).toBeTruthy()).toPass({ timeout: 45000 }) await expect(() => expect(linkToDoc).toBeTruthy()).toPass({ timeout: POLL_TOPASS_TIMEOUT })
const linkDocHref = await linkToDoc.getAttribute('href') const linkDocHref = await linkToDoc.getAttribute('href')
await linkToDoc.click() await linkToDoc.click()