fix(db-postgres): ensure deletion of numbers and texts in upsertRow (#11787)

### What?
This PR fixes an issue while using `text` & `number` fields with
`hasMany: true` where the last entry would be unreachable, and thus
undeletable, because the `transformForWrite` function did not track
these rows for deletion. This causes values that should've been deleted
to remain in the edit view form, as well as the db, after a submission.

This PR also properly threads the placeholder value from
`admin.placeholder` to `text` & `number` `hasMany: true` fields.

### Why?
To remove rows from the db when a submission is made where these fields
are empty arrays, and to properly show an appropriate placeholder when
one is set in config.

### How?
Adjusting `transformForWrite` and the `traverseFields` to keep track of
rows for deletion.

Fixes #11781

Before:


[Editing---Post-dbpg-before--Payload.webm](https://github.com/user-attachments/assets/5ba1708a-2672-4b36-ac68-05212f3aa6cb)

After:


[Editing---Post--dbpg-hasmany-after-Payload.webm](https://github.com/user-attachments/assets/1292e998-83ff-49d0-aa86-6199be319937)
This commit is contained in:
Said Akhrarov
2025-06-04 10:13:46 -04:00
committed by GitHub
parent c08cdff498
commit bd512f1eda
10 changed files with 158 additions and 17 deletions

View File

@@ -98,6 +98,8 @@ export const traverseFields = <T extends Record<string, unknown>>({
withinArrayOrBlockLocale,
}: TraverseFieldsArgs): T => {
const sanitizedPath = path ? `${path}.` : path
const localeCodes =
adapter.payload.config.localization && adapter.payload.config.localization.localeCodes
const formatted = fields.reduce((result, field) => {
if (fieldIsVirtual(field)) {
@@ -506,6 +508,10 @@ export const traverseFields = <T extends Record<string, unknown>>({
if (field.type === 'text' && field?.hasMany) {
const textPathMatch = texts[`${sanitizedPath}${field.name}`]
if (!textPathMatch) {
result[field.name] =
isLocalized && localeCodes
? Object.fromEntries(localeCodes.map((locale) => [locale, []]))
: []
return result
}
@@ -545,6 +551,10 @@ export const traverseFields = <T extends Record<string, unknown>>({
if (field.type === 'number' && field.hasMany) {
const numberPathMatch = numbers[`${sanitizedPath}${field.name}`]
if (!numberPathMatch) {
result[field.name] =
isLocalized && localeCodes
? Object.fromEntries(localeCodes.map((locale) => [locale, []]))
: []
return result
}
@@ -606,10 +616,8 @@ export const traverseFields = <T extends Record<string, unknown>>({
}
if (isLocalized && Array.isArray(table._locales)) {
if (!table._locales.length && adapter.payload.config.localization) {
adapter.payload.config.localization.localeCodes.forEach((_locale) =>
(table._locales as unknown[]).push({ _locale }),
)
if (!table._locales.length && localeCodes) {
localeCodes.forEach((_locale) => (table._locales as unknown[]).push({ _locale }))
}
table._locales.forEach((localeRow) => {
@@ -725,8 +733,6 @@ export const traverseFields = <T extends Record<string, unknown>>({
}
return result
return result
}, dataRef)
if (Array.isArray(table._locales)) {

View File

@@ -3,7 +3,13 @@ import type { FlattenedArrayField } from 'payload'
import { fieldShouldBeLocalized } from 'payload/shared'
import type { DrizzleAdapter } from '../../types.js'
import type { ArrayRowToInsert, BlockRowToInsert, RelationshipToDelete } from './types.js'
import type {
ArrayRowToInsert,
BlockRowToInsert,
NumberToDelete,
RelationshipToDelete,
TextToDelete,
} from './types.js'
import { isArrayOfRows } from '../../utilities/isArrayOfRows.js'
import { traverseFields } from './traverseFields.js'
@@ -20,6 +26,7 @@ type Args = {
field: FlattenedArrayField
locale?: string
numbers: Record<string, unknown>[]
numbersToDelete: NumberToDelete[]
parentIsLocalized: boolean
path: string
relationships: Record<string, unknown>[]
@@ -28,6 +35,7 @@ type Args = {
[tableName: string]: Record<string, unknown>[]
}
texts: Record<string, unknown>[]
textsToDelete: TextToDelete[]
/**
* Set to a locale code if this set of fields is traversed within a
* localized array or block field
@@ -45,12 +53,14 @@ export const transformArray = ({
field,
locale,
numbers,
numbersToDelete,
parentIsLocalized,
path,
relationships,
relationshipsToDelete,
selects,
texts,
textsToDelete,
withinArrayOrBlockLocale,
}: Args) => {
const newRows: ArrayRowToInsert[] = []
@@ -104,6 +114,7 @@ export const transformArray = ({
insideArrayOrBlock: true,
locales: newRow.locales,
numbers,
numbersToDelete,
parentIsLocalized: parentIsLocalized || field.localized,
parentTableName: arrayTableName,
path: `${path || ''}${field.name}.${i}.`,
@@ -112,6 +123,7 @@ export const transformArray = ({
row: newRow.row,
selects,
texts,
textsToDelete,
withinArrayOrBlockLocale,
})

View File

@@ -4,7 +4,12 @@ import { fieldShouldBeLocalized } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from '../../types.js'
import type { BlockRowToInsert, RelationshipToDelete } from './types.js'
import type {
BlockRowToInsert,
NumberToDelete,
RelationshipToDelete,
TextToDelete,
} from './types.js'
import { resolveBlockTableName } from '../../utilities/validateExistingBlockIsIdentical.js'
import { traverseFields } from './traverseFields.js'
@@ -20,6 +25,7 @@ type Args = {
field: FlattenedBlocksField
locale?: string
numbers: Record<string, unknown>[]
numbersToDelete: NumberToDelete[]
parentIsLocalized: boolean
path: string
relationships: Record<string, unknown>[]
@@ -28,6 +34,7 @@ type Args = {
[tableName: string]: Record<string, unknown>[]
}
texts: Record<string, unknown>[]
textsToDelete: TextToDelete[]
/**
* Set to a locale code if this set of fields is traversed within a
* localized array or block field
@@ -43,12 +50,14 @@ export const transformBlocks = ({
field,
locale,
numbers,
numbersToDelete,
parentIsLocalized,
path,
relationships,
relationshipsToDelete,
selects,
texts,
textsToDelete,
withinArrayOrBlockLocale,
}: Args) => {
data.forEach((blockRow, i) => {
@@ -117,6 +126,7 @@ export const transformBlocks = ({
insideArrayOrBlock: true,
locales: newRow.locales,
numbers,
numbersToDelete,
parentIsLocalized: parentIsLocalized || field.localized,
parentTableName: blockTableName,
path: `${path || ''}${field.name}.${i}.`,
@@ -125,6 +135,7 @@ export const transformBlocks = ({
row: newRow.row,
selects,
texts,
textsToDelete,
withinArrayOrBlockLocale,
})

View File

@@ -29,11 +29,13 @@ export const transformForWrite = ({
blocksToDelete: new Set(),
locales: {},
numbers: [],
numbersToDelete: [],
relationships: [],
relationshipsToDelete: [],
row: {},
selects: {},
texts: [],
textsToDelete: [],
}
// This function is responsible for building up the
@@ -50,6 +52,7 @@ export const transformForWrite = ({
fields,
locales: rowToInsert.locales,
numbers: rowToInsert.numbers,
numbersToDelete: rowToInsert.numbersToDelete,
parentIsLocalized,
parentTableName: tableName,
path,
@@ -58,6 +61,7 @@ export const transformForWrite = ({
row: rowToInsert.row,
selects: rowToInsert.selects,
texts: rowToInsert.texts,
textsToDelete: rowToInsert.textsToDelete,
})
return rowToInsert

View File

@@ -5,7 +5,13 @@ import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from '../../types.js'
import type { ArrayRowToInsert, BlockRowToInsert, RelationshipToDelete } from './types.js'
import type {
ArrayRowToInsert,
BlockRowToInsert,
NumberToDelete,
RelationshipToDelete,
TextToDelete,
} from './types.js'
import { isArrayOfRows } from '../../utilities/isArrayOfRows.js'
import { resolveBlockTableName } from '../../utilities/validateExistingBlockIsIdentical.js'
@@ -51,6 +57,7 @@ type Args = {
[locale: string]: Record<string, unknown>
}
numbers: Record<string, unknown>[]
numbersToDelete: NumberToDelete[]
parentIsLocalized: boolean
/**
* This is the name of the parent table
@@ -64,6 +71,7 @@ type Args = {
[tableName: string]: Record<string, unknown>[]
}
texts: Record<string, unknown>[]
textsToDelete: TextToDelete[]
/**
* Set to a locale code if this set of fields is traversed within a
* localized array or block field
@@ -86,6 +94,7 @@ export const traverseFields = ({
insideArrayOrBlock = false,
locales,
numbers,
numbersToDelete,
parentIsLocalized,
parentTableName,
path,
@@ -94,6 +103,7 @@ export const traverseFields = ({
row,
selects,
texts,
textsToDelete,
withinArrayOrBlockLocale,
}: Args) => {
if (row._uuid) {
@@ -136,12 +146,14 @@ export const traverseFields = ({
field,
locale: localeKey,
numbers,
numbersToDelete,
parentIsLocalized: parentIsLocalized || field.localized,
path,
relationships,
relationshipsToDelete,
selects,
texts,
textsToDelete,
withinArrayOrBlockLocale: localeKey,
})
@@ -159,12 +171,14 @@ export const traverseFields = ({
data: data[field.name],
field,
numbers,
numbersToDelete,
parentIsLocalized: parentIsLocalized || field.localized,
path,
relationships,
relationshipsToDelete,
selects,
texts,
textsToDelete,
withinArrayOrBlockLocale,
})
@@ -202,12 +216,14 @@ export const traverseFields = ({
field,
locale: localeKey,
numbers,
numbersToDelete,
parentIsLocalized: parentIsLocalized || field.localized,
path,
relationships,
relationshipsToDelete,
selects,
texts,
textsToDelete,
withinArrayOrBlockLocale: localeKey,
})
}
@@ -222,12 +238,14 @@ export const traverseFields = ({
data: fieldData,
field,
numbers,
numbersToDelete,
parentIsLocalized: parentIsLocalized || field.localized,
path,
relationships,
relationshipsToDelete,
selects,
texts,
textsToDelete,
withinArrayOrBlockLocale,
})
}
@@ -257,6 +275,7 @@ export const traverseFields = ({
insideArrayOrBlock,
locales,
numbers,
numbersToDelete,
parentIsLocalized: parentIsLocalized || field.localized,
parentTableName,
path: `${path || ''}${field.name}.`,
@@ -265,6 +284,7 @@ export const traverseFields = ({
row,
selects,
texts,
textsToDelete,
withinArrayOrBlockLocale: localeKey,
})
})
@@ -287,6 +307,7 @@ export const traverseFields = ({
insideArrayOrBlock,
locales,
numbers,
numbersToDelete,
parentIsLocalized: parentIsLocalized || field.localized,
parentTableName,
path: `${path || ''}${field.name}.`,
@@ -295,6 +316,7 @@ export const traverseFields = ({
row,
selects,
texts,
textsToDelete,
withinArrayOrBlockLocale,
})
}
@@ -380,6 +402,11 @@ export const traverseFields = ({
if (typeof fieldData === 'object') {
Object.entries(fieldData).forEach(([localeKey, localeData]) => {
if (Array.isArray(localeData)) {
if (!localeData.length) {
textsToDelete.push({ locale: localeKey, path: textPath })
return
}
transformTexts({
baseRow: {
locale: localeKey,
@@ -392,6 +419,11 @@ export const traverseFields = ({
})
}
} else if (Array.isArray(fieldData)) {
if (!fieldData.length) {
textsToDelete.push({ locale: withinArrayOrBlockLocale, path: textPath })
return
}
transformTexts({
baseRow: {
locale: withinArrayOrBlockLocale,
@@ -412,6 +444,11 @@ export const traverseFields = ({
if (typeof fieldData === 'object') {
Object.entries(fieldData).forEach(([localeKey, localeData]) => {
if (Array.isArray(localeData)) {
if (!localeData.length) {
numbersToDelete.push({ locale: localeKey, path: numberPath })
return
}
transformNumbers({
baseRow: {
locale: localeKey,
@@ -424,6 +461,11 @@ export const traverseFields = ({
})
}
} else if (Array.isArray(fieldData)) {
if (!fieldData.length) {
numbersToDelete.push({ locale: withinArrayOrBlockLocale, path: numberPath })
return
}
transformNumbers({
baseRow: {
locale: withinArrayOrBlockLocale,

View File

@@ -23,6 +23,16 @@ export type RelationshipToDelete = {
path: string
}
export type TextToDelete = {
locale?: string
path: string
}
export type NumberToDelete = {
locale?: string
path: string
}
export type RowToInsert = {
arrays: {
[tableName: string]: ArrayRowToInsert[]
@@ -35,6 +45,7 @@ export type RowToInsert = {
[locale: string]: Record<string, unknown>
}
numbers: Record<string, unknown>[]
numbersToDelete: NumberToDelete[]
relationships: Record<string, unknown>[]
relationshipsToDelete: RelationshipToDelete[]
row: Record<string, unknown>
@@ -42,4 +53,5 @@ export type RowToInsert = {
[tableName: string]: Record<string, unknown>[]
}
texts: Record<string, unknown>[]
textsToDelete: TextToDelete[]
}

View File

@@ -211,7 +211,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
parentColumnName: 'parent',
parentID: insertedRow.id,
pathColumnName: 'path',
rows: textsToInsert,
rows: [...textsToInsert, ...rowToInsert.textsToDelete],
tableName: textsTableName,
})
}
@@ -238,7 +238,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
parentColumnName: 'parent',
parentID: insertedRow.id,
pathColumnName: 'path',
rows: numbersToInsert,
rows: [...numbersToInsert, ...rowToInsert.numbersToDelete],
tableName: numbersTableName,
})
}

View File

@@ -26,7 +26,7 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => {
admin: {
className,
description,
placeholder,
placeholder: placeholderFromProps,
step = 1,
} = {} as NumberFieldClientProps['field']['admin'],
hasMany = false,
@@ -126,6 +126,8 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => {
const styles = useMemo(() => mergeFieldStyles(field), [field])
const placeholder = getTranslation(placeholderFromProps, i18n)
return (
<div
className={[
@@ -174,7 +176,7 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => {
// numberOnly
onChange={handleHasManyChange}
options={[]}
placeholder={t('general:enterAValue')}
placeholder={placeholder}
showError={showError}
value={valueToRender as Option[]}
/>
@@ -191,7 +193,7 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => {
// @ts-expect-error
e.target.blur()
}}
placeholder={getTranslation(placeholder, i18n)}
placeholder={placeholder}
step={step}
type="number"
value={typeof value === 'number' ? value : ''}

View File

@@ -34,7 +34,7 @@ export const TextInput: React.FC<TextInputProps> = (props) => {
onChange,
onKeyDown,
path,
placeholder,
placeholder: placeholderFromProps,
readOnly,
required,
rtl,
@@ -91,6 +91,8 @@ export const TextInput: React.FC<TextInputProps> = (props) => {
}
}
const placeholder = getTranslation(placeholderFromProps, i18n)
return (
<div
className={[
@@ -143,7 +145,7 @@ export const TextInput: React.FC<TextInputProps> = (props) => {
}}
onChange={onChange}
options={[]}
placeholder={t('general:enterAValue')}
placeholder={placeholder}
showError={showError}
value={valueToRender}
/>
@@ -155,7 +157,7 @@ export const TextInput: React.FC<TextInputProps> = (props) => {
name={path}
onChange={onChange as (e: ChangeEvent<HTMLInputElement>) => void}
onKeyDown={onKeyDown}
placeholder={getTranslation(placeholder, i18n)}
placeholder={placeholder}
ref={inputRef}
type="text"
value={value || ''}

View File

@@ -33,6 +33,7 @@ import {
checkboxFieldsSlug,
collapsibleFieldsSlug,
groupFieldsSlug,
numberFieldsSlug,
relationshipFieldsSlug,
tabsFieldsSlug,
textFieldsSlug,
@@ -401,6 +402,31 @@ describe('Fields', () => {
expect(resInSecond.totalDocs).toBe(1)
})
it('should delete rows when updating hasMany with empty array', async () => {
const { id: createdDocId } = await payload.create({
collection: textFieldsSlug,
data: {
text: 'hasMany deletion test',
hasMany: ['one', 'two', 'three'],
},
})
await payload.update({
collection: textFieldsSlug,
id: createdDocId,
data: {
hasMany: [],
},
})
const resultingDoc = await payload.findByID({
collection: textFieldsSlug,
id: createdDocId,
})
expect(resultingDoc.hasMany).toHaveLength(0)
})
})
describe('relationship', () => {
@@ -1042,6 +1068,30 @@ describe('Fields', () => {
expect(numbersNotExists.docs).toHaveLength(1)
})
it('should delete rows when updating hasMany with empty array', async () => {
const { id: createdDocId } = await payload.create({
collection: numberFieldsSlug,
data: {
localizedHasMany: [1, 2, 3],
},
})
await payload.update({
collection: numberFieldsSlug,
id: createdDocId,
data: {
localizedHasMany: [],
},
})
const resultingDoc = await payload.findByID({
collection: numberFieldsSlug,
id: createdDocId,
})
expect(resultingDoc.localizedHasMany).toHaveLength(0)
})
})
it('should query hasMany within an array', async () => {