Files
payload/packages/ui/src/forms/Form/fieldReducer.ts
2024-04-09 12:31:25 -04:00

380 lines
10 KiB
TypeScript

import type { FormField, FormState, Row } from 'payload/types'
import ObjectIdImport from 'bson-objectid'
import equal from 'deep-equal'
import { deepCopyObject } from 'payload/utilities'
import type { FieldAction } from './types.js'
import { flattenRows, separateRows } from './rows.js'
const ObjectId = (ObjectIdImport.default ||
ObjectIdImport) as unknown as typeof ObjectIdImport.default
/**
* Reducer which modifies the form field state (all the current data of the fields in the form). When called using dispatch, it will return a new state object.
*/
export function fieldReducer(state: FormState, action: FieldAction): FormState {
switch (action.type) {
case 'REPLACE_STATE': {
if (action.optimize !== false) {
// Only update fields that have changed
// by comparing old value / initialValue to new
// ..
// This is a performance enhancement for saving
// large documents with hundreds of fields
const newState = {}
Object.entries(action.state).forEach(([path, field]) => {
const oldField = state[path]
const newField = field
if (!equal(oldField, newField)) {
newState[path] = newField
} else if (oldField) {
newState[path] = oldField
}
})
return newState
}
// If we're not optimizing, just set the state to the new state
return action.state
}
case 'REMOVE': {
const newState = { ...state }
if (newState[action.path]) delete newState[action.path]
return newState
}
case 'ADD_SERVER_ERRORS': {
let newState = { ...state }
const errorPaths: { fieldErrorPath: string; parentPath: string }[] = []
action.errors.forEach(({ field, message }) => {
newState[field] = {
...(newState[field] || {
initialValue: null,
value: null,
}),
errorMessage: message,
valid: false,
}
const segments = field.split('.')
if (segments.length > 1) {
errorPaths.push({
fieldErrorPath: field,
parentPath: segments.slice(0, segments.length - 1).join('.'),
})
}
})
newState = Object.entries(newState).reduce((acc, [path, fieldState]) => {
const fieldErrorPaths = errorPaths.reduce((errorACC, { fieldErrorPath, parentPath }) => {
if (parentPath.startsWith(path)) {
errorACC.push(fieldErrorPath)
}
return errorACC
}, [])
let changed = false
if (fieldErrorPaths.length > 0) {
const newErrorPaths = Array.isArray(fieldState.errorPaths) ? fieldState.errorPaths : []
fieldErrorPaths.forEach((fieldErrorPath) => {
if (!newErrorPaths.includes(fieldErrorPath)) {
newErrorPaths.push(fieldErrorPath)
changed = true
}
})
if (changed) {
acc[path] = {
...fieldState,
errorPaths: newErrorPaths,
}
}
}
if (!changed) {
acc[path] = fieldState
}
return acc
}, {})
return newState
}
case 'UPDATE': {
const newField = Object.entries(action).reduce(
(field, [key, value]) => {
if (
[
'disableFormData',
'errorMessage',
'initialValue',
'rows',
'valid',
'validate',
'value',
].includes(key)
) {
return {
...field,
[key]: value,
}
}
return field
},
state[action.path] || ({} as FormField),
)
const newState = {
...state,
[action.path]: newField,
}
return newState
}
case 'REMOVE_ROW': {
const { path, rowIndex } = action
const { remainingFields, rows } = separateRows(path, state)
const rowsMetadata = [...(state[path]?.rows || [])]
rows.splice(rowIndex, 1)
rowsMetadata.splice(rowIndex, 1)
const newState: FormState = {
...remainingFields,
[path]: {
...state[path],
disableFormData: rows.length > 0,
rows: rowsMetadata,
value: rows.length,
},
...flattenRows(path, rows),
}
return newState
}
case 'ADD_ROW': {
const { blockType, path, rowIndex: rowIndexFromArgs, subFieldState = {} } = action
const rowIndex =
typeof rowIndexFromArgs === 'number' ? rowIndexFromArgs : state[path]?.rows?.length || 0
const withNewRow = [...(state[path]?.rows || [])]
const newRow: Row = {
id: (subFieldState?.id?.value as string) || new ObjectId().toHexString(),
blockType: blockType || undefined,
collapsed: false,
}
withNewRow.splice(rowIndex, 0, newRow)
if (blockType) {
subFieldState.blockType = {
initialValue: blockType,
valid: true,
value: blockType,
}
}
// add new row to array _field state_
const { remainingFields, rows: siblingRows } = separateRows(path, state)
siblingRows.splice(rowIndex, 0, subFieldState)
const newState: FormState = {
...remainingFields,
...flattenRows(path, siblingRows),
[path]: {
...state[path],
disableFormData: true,
rows: withNewRow,
value: siblingRows.length,
},
}
return newState
}
case 'REPLACE_ROW': {
const { blockType, path, rowIndex: rowIndexArg, subFieldState = {} } = action
const { remainingFields, rows: siblingRows } = separateRows(path, state)
const rowIndex = Math.max(0, Math.min(rowIndexArg, siblingRows?.length - 1 || 0))
const rowsMetadata = [...(state[path]?.rows || [])]
rowsMetadata[rowIndex] = {
id: new ObjectId().toHexString(),
blockType: blockType || undefined,
collapsed: false,
}
if (blockType) {
subFieldState.blockType = {
initialValue: blockType,
valid: true,
value: blockType,
}
}
// replace form _field state_
siblingRows[rowIndex] = subFieldState
const newState: FormState = {
...remainingFields,
...flattenRows(path, siblingRows),
[path]: {
...state[path],
disableFormData: true,
rows: rowsMetadata,
value: siblingRows.length,
},
}
return newState
}
case 'DUPLICATE_ROW': {
const { path, rowIndex } = action
const { remainingFields, rows } = separateRows(path, state)
const rowsMetadata = state[path]?.rows || []
const duplicateRowMetadata = deepCopyObject(rowsMetadata[rowIndex])
if (duplicateRowMetadata.id) duplicateRowMetadata.id = new ObjectId().toHexString()
const duplicateRowState = deepCopyObject(rows[rowIndex])
if (duplicateRowState.id) duplicateRowState.id = new ObjectId().toHexString()
// If there are subfields
if (Object.keys(duplicateRowState).length > 0) {
// Add new object containing subfield names to unflattenedRows array
rows.splice(rowIndex + 1, 0, duplicateRowState)
rowsMetadata.splice(rowIndex + 1, 0, duplicateRowMetadata)
}
const newState = {
...remainingFields,
[path]: {
...state[path],
disableFormData: true,
rows: rowsMetadata,
value: rows.length,
},
...flattenRows(path, rows),
}
return newState
}
case 'MOVE_ROW': {
const { moveFromIndex, moveToIndex, path } = action
const { remainingFields, rows } = separateRows(path, state)
// copy the row to move
const copyOfMovingRow = rows[moveFromIndex]
// delete the row by index
rows.splice(moveFromIndex, 1)
// insert row copyOfMovingRow back in
rows.splice(moveToIndex, 0, copyOfMovingRow)
// modify array/block internal row state (i.e. collapsed, blockType)
const rowStateCopy = [...(state[path]?.rows || [])]
const movingRowState = { ...rowStateCopy[moveFromIndex] }
rowStateCopy.splice(moveFromIndex, 1)
rowStateCopy.splice(moveToIndex, 0, movingRowState)
const newState = {
...remainingFields,
...flattenRows(path, rows),
[path]: {
...state[path],
rows: rowStateCopy,
},
}
return newState
}
case 'SET_ROW_COLLAPSED': {
const { collapsed, path, rowID, setDocFieldPreferences } = action
const arrayState = state[path]
const { collapsedRowIDs, matchedIndex } = state[path].rows.reduce(
(acc, row, index) => {
const isMatchingRow = row.id === rowID
if (isMatchingRow) acc.matchedIndex = index
if (!isMatchingRow && row.collapsed) acc.collapsedRowIDs.push(row.id)
else if (isMatchingRow && collapsed) acc.collapsedRowIDs.push(row.id)
return acc
},
{
collapsedRowIDs: [],
matchedIndex: undefined,
},
)
if (matchedIndex > -1) {
arrayState.rows[matchedIndex].collapsed = collapsed
setDocFieldPreferences(path, { collapsed: collapsedRowIDs })
}
const newState = {
...state,
[path]: {
...arrayState,
},
}
return newState
}
case 'SET_ALL_ROWS_COLLAPSED': {
const { collapsed, path, setDocFieldPreferences } = action
const { collapsedRowIDs, rows } = state[path].rows.reduce(
(acc, row) => {
if (collapsed) acc.collapsedRowIDs.push(row.id)
acc.rows.push({
...row,
collapsed,
})
return acc
},
{
collapsedRowIDs: [],
rows: [],
},
)
setDocFieldPreferences(path, { collapsed: collapsedRowIDs })
return {
...state,
[path]: {
...state[path],
rows,
},
}
}
default: {
return state
}
}
}