diff --git a/docs/fields/text.mdx b/docs/fields/text.mdx index 560986e5ea..82b0bfd20d 100644 --- a/docs/fields/text.mdx +++ b/docs/fields/text.mdx @@ -38,7 +38,9 @@ keywords: text, fields, config, configuration, documentation, Content Management | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | - +| **`hasMany`** | Makes this field an ordered array of text instead of just a single text. | +| **`minRows`** | Minimum number of texts in the array, if `hasMany` is set to true. | +| **`maxRows`** | Maximum number of texts in the array, if `hasMany` is set to true. | _\* An asterisk denotes that a property is required._ ### Admin config diff --git a/packages/db-mongodb/src/models/buildSchema.ts b/packages/db-mongodb/src/models/buildSchema.ts index 619e399e09..fb10fe34d9 100644 --- a/packages/db-mongodb/src/models/buildSchema.ts +++ b/packages/db-mongodb/src/models/buildSchema.ts @@ -547,7 +547,10 @@ const fieldToSchemaMap: Record = { config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, ): void => { - const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String } + const baseSchema = { + ...formatBaseSchema(field, buildSchemaOptions), + type: field.hasMany ? [String] : String, + } schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), diff --git a/packages/db-postgres/src/find/buildFindManyArgs.ts b/packages/db-postgres/src/find/buildFindManyArgs.ts index f91780f64c..1390d3ab7a 100644 --- a/packages/db-postgres/src/find/buildFindManyArgs.ts +++ b/packages/db-postgres/src/find/buildFindManyArgs.ts @@ -33,6 +33,16 @@ export const buildFindManyArgs = ({ }, } + if (adapter.tables[`${tableName}_texts`]) { + result.with._texts = { + columns: { + id: false, + parent: false, + }, + orderBy: ({ order }, { asc: ASC }) => [ASC(order)], + } + } + if (adapter.tables[`${tableName}_numbers`]) { result.with._numbers = { columns: { diff --git a/packages/db-postgres/src/init.ts b/packages/db-postgres/src/init.ts index 34256b8565..233508144c 100644 --- a/packages/db-postgres/src/init.ts +++ b/packages/db-postgres/src/init.ts @@ -24,6 +24,7 @@ export const init: Init = async function init(this: PostgresAdapter) { buildTable({ adapter: this, + buildTexts: true, buildNumbers: true, buildRelationships: true, disableNotNull: !!collection?.versions?.drafts, @@ -41,6 +42,7 @@ export const init: Init = async function init(this: PostgresAdapter) { buildTable({ adapter: this, + buildTexts: true, buildNumbers: true, buildRelationships: true, disableNotNull: !!collection.versions?.drafts, @@ -57,6 +59,7 @@ export const init: Init = async function init(this: PostgresAdapter) { buildTable({ adapter: this, + buildTexts: true, buildNumbers: true, buildRelationships: true, disableNotNull: !!global?.versions?.drafts, @@ -72,6 +75,7 @@ export const init: Init = async function init(this: PostgresAdapter) { buildTable({ adapter: this, + buildTexts: true, buildNumbers: true, buildRelationships: true, disableNotNull: !!global.versions?.drafts, diff --git a/packages/db-postgres/src/schema/build.ts b/packages/db-postgres/src/schema/build.ts index faab3b8078..6ee737824b 100644 --- a/packages/db-postgres/src/schema/build.ts +++ b/packages/db-postgres/src/schema/build.ts @@ -27,6 +27,7 @@ type Args = { adapter: PostgresAdapter baseColumns?: Record baseExtraConfig?: Record IndexBuilder | UniqueConstraintBuilder> + buildTexts?: boolean buildNumbers?: boolean buildRelationships?: boolean disableNotNull: boolean @@ -41,6 +42,7 @@ type Args = { } type Result = { + hasManyTextField: 'index' | boolean hasManyNumberField: 'index' | boolean relationsToBuild: Map } @@ -49,6 +51,7 @@ export const buildTable = ({ adapter, baseColumns = {}, baseExtraConfig = {}, + buildTexts, buildNumbers, buildRelationships, disableNotNull, @@ -67,12 +70,15 @@ export const buildTable = ({ let hasLocalizedField = false let hasLocalizedRelationshipField = false + let hasManyTextField: 'index' | boolean = false let hasManyNumberField: 'index' | boolean = false + let hasLocalizedManyTextField = false let hasLocalizedManyNumberField = false const localesColumns: Record = {} const localesIndexes: Record IndexBuilder> = {} let localesTable: GenericTable + let textsTable: GenericTable let numbersTable: GenericTable // Relationships to the base collection @@ -94,11 +100,14 @@ export const buildTable = ({ columns.id = idColTypeMap[idColType]('id').primaryKey() ;({ hasLocalizedField, + hasLocalizedManyTextField, hasLocalizedManyNumberField, hasLocalizedRelationshipField, + hasManyTextField, hasManyNumberField, } = traverseFields({ adapter, + buildTexts, buildNumbers, buildRelationships, columns, @@ -183,6 +192,50 @@ export const buildTable = ({ adapter.relations[`relations_${localeTableName}`] = localesTableRelations } + if (hasManyTextField && buildTexts) { + const textsTableName = `${rootTableName}_texts` + const columns: Record = { + id: serial('id').primaryKey(), + text: varchar('text'), + order: integer('order').notNull(), + parent: parentIDColumnMap[idColType]('parent_id') + .references(() => table.id, { onDelete: 'cascade' }) + .notNull(), + path: varchar('path').notNull(), + } + + if (hasLocalizedManyTextField) { + columns.locale = adapter.enums.enum__locales('locale') + } + + textsTable = pgTable(textsTableName, columns, (cols) => { + const indexes: Record = { + orderParentIdx: index('order_parent_idx').on(cols.order, cols.parent), + } + + if (hasManyTextField === 'index') { + indexes.text_idx = index('text_idx').on(cols.text) + } + + if (hasLocalizedManyTextField) { + indexes.localeParent = index('locale_parent').on(cols.locale, cols.parent) + } + + return indexes + }) + + adapter.tables[textsTableName] = textsTable + + const textsTableRelations = relations(textsTable, ({ one }) => ({ + parent: one(table, { + fields: [textsTable.parent], + references: [table.id], + }), + })) + + adapter.relations[`relations_${textsTableName}`] = textsTableRelations + } + if (hasManyNumberField && buildNumbers) { const numbersTableName = `${rootTableName}_numbers` const columns: Record = { @@ -310,6 +363,9 @@ export const buildTable = ({ result._locales = many(localesTable) } + if (hasManyTextField) { + result._texts = many(textsTable) + } if (hasManyNumberField) { result._numbers = many(numbersTable) } @@ -325,5 +381,5 @@ export const buildTable = ({ adapter.relations[`relations_${tableName}`] = tableRelations - return { hasManyNumberField, relationsToBuild } + return { hasManyTextField, hasManyNumberField, relationsToBuild } } diff --git a/packages/db-postgres/src/schema/traverseFields.ts b/packages/db-postgres/src/schema/traverseFields.ts index d916bdd22c..4815d61fe2 100644 --- a/packages/db-postgres/src/schema/traverseFields.ts +++ b/packages/db-postgres/src/schema/traverseFields.ts @@ -32,6 +32,7 @@ import { validateExistingBlockIsIdentical } from './validateExistingBlockIsIdent type Args = { adapter: PostgresAdapter + buildTexts: boolean buildNumbers: boolean buildRelationships: boolean columnPrefix?: string @@ -55,13 +56,16 @@ type Args = { type Result = { hasLocalizedField: boolean + hasLocalizedManyTextField: boolean hasLocalizedManyNumberField: boolean hasLocalizedRelationshipField: boolean + hasManyTextField: 'index' | boolean hasManyNumberField: 'index' | boolean } export const traverseFields = ({ adapter, + buildTexts, buildNumbers, buildRelationships, columnPrefix, @@ -84,6 +88,8 @@ export const traverseFields = ({ }: Args): Result => { let hasLocalizedField = false let hasLocalizedRelationshipField = false + let hasManyTextField: 'index' | boolean = false + let hasLocalizedManyTextField = false let hasManyNumberField: 'index' | boolean = false let hasLocalizedManyNumberField = false @@ -135,7 +141,28 @@ export const traverseFields = ({ } switch (field.type) { - case 'text': + case 'text': { + if (field.hasMany) { + if (field.localized) { + hasLocalizedManyTextField = true + } + + if (field.index) { + hasManyTextField = 'index' + } else if (!hasManyTextField) { + hasManyTextField = true + } + + if (field.unique) { + throw new InvalidConfiguration( + 'Unique is not supported in Postgres for hasMany text fields.', + ) + } + } else { + targetTable[fieldName] = varchar(columnName) + } + break + } case 'email': case 'code': case 'textarea': { @@ -286,21 +313,28 @@ export const traverseFields = ({ baseExtraConfig._localeIdx = (cols) => index('_locale_idx').on(cols._locale) } - const { hasManyNumberField: subHasManyNumberField, relationsToBuild: subRelationsToBuild } = - buildTable({ - adapter, - baseColumns, - baseExtraConfig, - disableNotNull: disableNotNullFromHere, - disableUnique, - fields: disableUnique ? idToUUID(field.fields) : field.fields, - rootRelationsToBuild, - rootRelationships: relationships, - rootTableIDColType, - rootTableName, - tableName: arrayTableName, - }) + const { + hasManyTextField: subHasManyTextField, + hasManyNumberField: subHasManyNumberField, + relationsToBuild: subRelationsToBuild, + } = buildTable({ + adapter, + baseColumns, + baseExtraConfig, + disableNotNull: disableNotNullFromHere, + disableUnique, + fields: disableUnique ? idToUUID(field.fields) : field.fields, + rootRelationsToBuild, + rootRelationships: relationships, + rootTableIDColType, + rootTableName, + tableName: arrayTableName, + }) + if (subHasManyTextField) { + if (!hasManyTextField || subHasManyTextField === 'index') + hasManyTextField = subHasManyTextField + } if (subHasManyNumberField) { if (!hasManyNumberField || subHasManyNumberField === 'index') hasManyNumberField = subHasManyNumberField @@ -361,6 +395,7 @@ export const traverseFields = ({ } const { + hasManyTextField: subHasManyTextField, hasManyNumberField: subHasManyNumberField, relationsToBuild: subRelationsToBuild, } = buildTable({ @@ -377,6 +412,11 @@ export const traverseFields = ({ tableName: blockTableName, }) + if (subHasManyTextField) { + if (!hasManyTextField || subHasManyTextField === 'index') + hasManyTextField = subHasManyTextField + } + if (subHasManyNumberField) { if (!hasManyNumberField || subHasManyNumberField === 'index') hasManyNumberField = subHasManyNumberField @@ -425,11 +465,14 @@ export const traverseFields = ({ if (!('name' in field)) { const { hasLocalizedField: groupHasLocalizedField, + hasLocalizedManyTextField: groupHasLocalizedManyTextField, hasLocalizedManyNumberField: groupHasLocalizedManyNumberField, hasLocalizedRelationshipField: groupHasLocalizedRelationshipField, + hasManyTextField: groupHasManyTextField, hasManyNumberField: groupHasManyNumberField, } = traverseFields({ adapter, + buildTexts, buildNumbers, buildRelationships, columnPrefix, @@ -453,6 +496,8 @@ export const traverseFields = ({ if (groupHasLocalizedField) hasLocalizedField = true if (groupHasLocalizedRelationshipField) hasLocalizedRelationshipField = true + if (groupHasManyTextField) hasManyTextField = true + if (groupHasLocalizedManyTextField) hasLocalizedManyTextField = true if (groupHasManyNumberField) hasManyNumberField = true if (groupHasLocalizedManyNumberField) hasLocalizedManyNumberField = true break @@ -462,11 +507,14 @@ export const traverseFields = ({ const { hasLocalizedField: groupHasLocalizedField, + hasLocalizedManyTextField: groupHasLocalizedManyTextField, hasLocalizedManyNumberField: groupHasLocalizedManyNumberField, hasLocalizedRelationshipField: groupHasLocalizedRelationshipField, + hasManyTextField: groupHasManyTextField, hasManyNumberField: groupHasManyNumberField, } = traverseFields({ adapter, + buildTexts, buildNumbers, buildRelationships, columnPrefix: `${columnName}_`, @@ -490,6 +538,8 @@ export const traverseFields = ({ if (groupHasLocalizedField) hasLocalizedField = true if (groupHasLocalizedRelationshipField) hasLocalizedRelationshipField = true + if (groupHasManyTextField) hasManyTextField = true + if (groupHasLocalizedManyTextField) hasLocalizedManyTextField = true if (groupHasManyNumberField) hasManyNumberField = true if (groupHasLocalizedManyNumberField) hasLocalizedManyNumberField = true break @@ -500,11 +550,14 @@ export const traverseFields = ({ const { hasLocalizedField: tabHasLocalizedField, + hasLocalizedManyTextField: tabHasLocalizedManyTextField, hasLocalizedManyNumberField: tabHasLocalizedManyNumberField, hasLocalizedRelationshipField: tabHasLocalizedRelationshipField, + hasManyTextField: tabHasManyTextField, hasManyNumberField: tabHasManyNumberField, } = traverseFields({ adapter, + buildTexts, buildNumbers, buildRelationships, columnPrefix, @@ -528,9 +581,10 @@ export const traverseFields = ({ if (tabHasLocalizedField) hasLocalizedField = true if (tabHasLocalizedRelationshipField) hasLocalizedRelationshipField = true + if (tabHasManyTextField) hasManyTextField = true + if (tabHasLocalizedManyTextField) hasLocalizedManyTextField = true if (tabHasManyNumberField) hasManyNumberField = true if (tabHasLocalizedManyNumberField) hasLocalizedManyNumberField = true - break } @@ -539,11 +593,14 @@ export const traverseFields = ({ const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull const { hasLocalizedField: rowHasLocalizedField, + hasLocalizedManyTextField: rowHasLocalizedManyTextField, hasLocalizedManyNumberField: rowHasLocalizedManyNumberField, hasLocalizedRelationshipField: rowHasLocalizedRelationshipField, + hasManyTextField: rowHasManyTextField, hasManyNumberField: rowHasManyNumberField, } = traverseFields({ adapter, + buildTexts, buildNumbers, buildRelationships, columnPrefix, @@ -567,6 +624,8 @@ export const traverseFields = ({ if (rowHasLocalizedField) hasLocalizedField = true if (rowHasLocalizedRelationshipField) hasLocalizedRelationshipField = true + if (rowHasManyTextField) hasManyTextField = true + if (rowHasLocalizedManyTextField) hasLocalizedManyTextField = true if (rowHasManyNumberField) hasManyNumberField = true if (rowHasLocalizedManyNumberField) hasLocalizedManyNumberField = true break @@ -604,8 +663,10 @@ export const traverseFields = ({ return { hasLocalizedField, + hasLocalizedManyTextField, hasLocalizedManyNumberField, hasLocalizedRelationshipField, + hasManyTextField, hasManyNumberField, } } diff --git a/packages/db-postgres/src/transform/read/hasManyText.ts b/packages/db-postgres/src/transform/read/hasManyText.ts new file mode 100644 index 0000000000..af6b8e836e --- /dev/null +++ b/packages/db-postgres/src/transform/read/hasManyText.ts @@ -0,0 +1,19 @@ +/* eslint-disable no-param-reassign */ +import type { TextField } from 'payload/types' + +type Args = { + field: TextField + locale?: string + textRows: Record[] + ref: Record +} + +export const transformHasManyText = ({ field, locale, textRows, ref }: Args) => { + const result = textRows.map(({ text }) => text) + + if (locale) { + ref[field.name][locale] = result + } else { + ref[field.name] = result + } +} diff --git a/packages/db-postgres/src/transform/read/index.ts b/packages/db-postgres/src/transform/read/index.ts index e2f74d21df..57a44acad2 100644 --- a/packages/db-postgres/src/transform/read/index.ts +++ b/packages/db-postgres/src/transform/read/index.ts @@ -18,6 +18,7 @@ type TransformArgs = { // into the shape Payload expects based on field schema export const transform = ({ config, data, fields }: TransformArgs): T => { let relationships: Record[]> = {} + let texts: Record[]> = {} let numbers: Record[]> = {} if ('_rels' in data) { @@ -25,6 +26,11 @@ export const transform = ({ config, data, fields }: Transf delete data._rels } + if ('_texts' in data) { + texts = createPathMap(data._texts) + delete data._texts + } + if ('_numbers' in data) { numbers = createPathMap(data._numbers) delete data._numbers @@ -42,6 +48,7 @@ export const transform = ({ config, data, fields }: Transf deletions, fieldPrefix: '', fields, + texts, numbers, path: '', relationships, diff --git a/packages/db-postgres/src/transform/read/traverseFields.ts b/packages/db-postgres/src/transform/read/traverseFields.ts index 40cc3cec9c..2bb0ba42c2 100644 --- a/packages/db-postgres/src/transform/read/traverseFields.ts +++ b/packages/db-postgres/src/transform/read/traverseFields.ts @@ -8,6 +8,7 @@ import type { BlocksMap } from '../../utilities/createBlocksMap' import { transformHasManyNumber } from './hasManyNumber' import { transformRelationship } from './relationship' +import { transformHasManyText } from './hasManyText' type TraverseFieldsArgs = { /** @@ -34,6 +35,10 @@ type TraverseFieldsArgs = { * An array of Payload fields to traverse */ fields: (Field | TabAsField)[] + /** + * All hasMany text fields, as returned by Drizzle, keyed on an object by field path + */ + texts: Record[]> /** * All hasMany number fields, as returned by Drizzle, keyed on an object by field path */ @@ -61,6 +66,7 @@ export const traverseFields = >({ deletions, fieldPrefix, fields, + texts, numbers, path, relationships, @@ -77,6 +83,7 @@ export const traverseFields = >({ deletions, fieldPrefix, fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })), + texts, numbers, path, relationships, @@ -96,6 +103,7 @@ export const traverseFields = >({ deletions, fieldPrefix, fields: field.fields, + texts, numbers, path, relationships, @@ -127,6 +135,7 @@ export const traverseFields = >({ deletions, fieldPrefix: '', fields: field.fields, + texts, numbers, path: `${sanitizedPath}${field.name}.${row._order - 1}`, relationships, @@ -151,6 +160,7 @@ export const traverseFields = >({ deletions, fieldPrefix: '', fields: field.fields, + texts, numbers, path: `${sanitizedPath}${field.name}.${i}`, relationships, @@ -194,6 +204,7 @@ export const traverseFields = >({ deletions, fieldPrefix: '', fields: block.fields, + texts, numbers, path: `${blockFieldPath}.${row._order - 1}`, relationships, @@ -224,6 +235,7 @@ export const traverseFields = >({ deletions, fieldPrefix: '', fields: block.fields, + texts, numbers, path: `${blockFieldPath}.${i}`, relationships, @@ -285,6 +297,40 @@ export const traverseFields = >({ return result } + if (field.type === 'text' && field?.hasMany) { + const textPathMatch = texts[`${sanitizedPath}${field.name}`] + if (!textPathMatch) return result + + if (field.localized) { + result[field.name] = {} + const textsByLocale: Record[]> = {} + + textPathMatch.forEach((row) => { + if (typeof row.locale === 'string') { + if (!textsByLocale[row.locale]) textsByLocale[row.locale] = [] + textsByLocale[row.locale].push(row) + } + }) + + Object.entries(textsByLocale).forEach(([locale, texts]) => { + transformHasManyText({ + field, + locale, + textRows: texts, + ref: result, + }) + }) + } else { + transformHasManyText({ + field, + textRows: textPathMatch, + ref: result, + }) + } + + return result + } + if (field.type === 'number' && field.hasMany) { const numberPathMatch = numbers[`${sanitizedPath}${field.name}`] if (!numberPathMatch) return result @@ -374,6 +420,7 @@ export const traverseFields = >({ deletions, fieldPrefix: groupFieldPrefix, fields: field.fields, + texts, numbers, path: `${sanitizedPath}${field.name}`, relationships, @@ -390,6 +437,7 @@ export const traverseFields = >({ deletions, fieldPrefix: groupFieldPrefix, fields: field.fields, + texts, numbers, path: `${sanitizedPath}${field.name}`, relationships, @@ -400,6 +448,21 @@ export const traverseFields = >({ break } + case 'text': { + let val = fieldData + if (typeof fieldData === 'string') { + val = String(fieldData) + } + + if (typeof locale === 'string') { + ref[locale] = val + } else { + result[field.name] = val + } + + break + } + case 'number': { let val = fieldData if (typeof fieldData === 'string') { diff --git a/packages/db-postgres/src/transform/write/array.ts b/packages/db-postgres/src/transform/write/array.ts index 4c963a2b69..fd64ad1cef 100644 --- a/packages/db-postgres/src/transform/write/array.ts +++ b/packages/db-postgres/src/transform/write/array.ts @@ -18,6 +18,7 @@ type Args = { data: unknown field: ArrayField locale?: string + texts: Record[] numbers: Record[] path: string relationships: Record[] @@ -36,6 +37,7 @@ export const transformArray = ({ data, field, locale, + texts, numbers, path, relationships, @@ -86,6 +88,7 @@ export const transformArray = ({ fieldPrefix: '', fields: field.fields, locales: newRow.locales, + texts, numbers, parentTableName: arrayTableName, path: `${path || ''}${field.name}.${i}.`, diff --git a/packages/db-postgres/src/transform/write/blocks.ts b/packages/db-postgres/src/transform/write/blocks.ts index 2db20d2bf7..dc80d03657 100644 --- a/packages/db-postgres/src/transform/write/blocks.ts +++ b/packages/db-postgres/src/transform/write/blocks.ts @@ -18,6 +18,7 @@ type Args = { data: Record[] field: BlockField locale?: string + texts: Record[] numbers: Record[] path: string relationships: Record[] @@ -34,6 +35,7 @@ export const transformBlocks = ({ data, field, locale, + texts, numbers, path, relationships, @@ -84,6 +86,7 @@ export const transformBlocks = ({ fieldPrefix: '', fields: matchedBlock.fields, locales: newRow.locales, + texts, numbers, parentTableName: blockTableName, path: `${path || ''}${field.name}.${i}.`, diff --git a/packages/db-postgres/src/transform/write/index.ts b/packages/db-postgres/src/transform/write/index.ts index 42253c8c6b..607bfaf03e 100644 --- a/packages/db-postgres/src/transform/write/index.ts +++ b/packages/db-postgres/src/transform/write/index.ts @@ -27,6 +27,7 @@ export const transformForWrite = ({ blocks: {}, blocksToDelete: new Set(), locales: {}, + texts: [], numbers: [], relationships: [], relationshipsToDelete: [], @@ -47,6 +48,7 @@ export const transformForWrite = ({ fieldPrefix: '', fields, locales: rowToInsert.locales, + texts: rowToInsert.texts, numbers: rowToInsert.numbers, parentTableName: tableName, path, diff --git a/packages/db-postgres/src/transform/write/texts.ts b/packages/db-postgres/src/transform/write/texts.ts new file mode 100644 index 0000000000..5cf396349c --- /dev/null +++ b/packages/db-postgres/src/transform/write/texts.ts @@ -0,0 +1,15 @@ +type Args = { + baseRow: Record + data: unknown[] + texts: Record[] +} + +export const transformTexts = ({ baseRow, data, texts }: Args) => { + data.forEach((val, i) => { + texts.push({ + ...baseRow, + text: val, + order: i + 1, + }) + }) +} diff --git a/packages/db-postgres/src/transform/write/traverseFields.ts b/packages/db-postgres/src/transform/write/traverseFields.ts index cb0d2427d7..0a3b57fa7c 100644 --- a/packages/db-postgres/src/transform/write/traverseFields.ts +++ b/packages/db-postgres/src/transform/write/traverseFields.ts @@ -13,6 +13,7 @@ import { transformBlocks } from './blocks' import { transformNumbers } from './numbers' import { transformRelationship } from './relationships' import { transformSelects } from './selects' +import { transformTexts } from './texts' type Args = { adapter: PostgresAdapter @@ -44,6 +45,7 @@ type Args = { locales: { [locale: string]: Record } + texts: Record[] numbers: Record[] /** * This is the name of the parent table @@ -71,6 +73,7 @@ export const traverseFields = ({ fields, forcedLocale, locales, + texts, numbers, parentTableName, path, @@ -108,6 +111,7 @@ export const traverseFields = ({ data: localeData, field, locale: localeKey, + texts, numbers, path, relationships, @@ -128,6 +132,7 @@ export const traverseFields = ({ blocksToDelete, data: data[field.name], field, + texts, numbers, path, relationships, @@ -158,6 +163,7 @@ export const traverseFields = ({ data: localeData, field, locale: localeKey, + texts, numbers, path, relationships, @@ -175,6 +181,7 @@ export const traverseFields = ({ blocksToDelete, data: fieldData, field, + texts, numbers, path, relationships, @@ -203,6 +210,7 @@ export const traverseFields = ({ fields: field.fields, forcedLocale: localeKey, locales, + texts, numbers, parentTableName, path: `${path || ''}${field.name}.`, @@ -225,6 +233,7 @@ export const traverseFields = ({ fieldPrefix: `${fieldName}_`, fields: field.fields, locales, + texts, numbers, parentTableName, path: `${path || ''}${field.name}.`, @@ -258,6 +267,7 @@ export const traverseFields = ({ fields: tab.fields, forcedLocale: localeKey, locales, + texts, numbers, parentTableName, path: `${path || ''}${tab.name}.`, @@ -280,6 +290,7 @@ export const traverseFields = ({ fieldPrefix: `${fieldPrefix || ''}${tab.name}_`, fields: tab.fields, locales, + texts, numbers, parentTableName, path: `${path || ''}${tab.name}.`, @@ -303,6 +314,7 @@ export const traverseFields = ({ fieldPrefix, fields: tab.fields, locales, + texts, numbers, parentTableName, path, @@ -328,6 +340,7 @@ export const traverseFields = ({ fieldPrefix, fields: field.fields, locales, + texts, numbers, parentTableName, path, @@ -382,6 +395,37 @@ export const traverseFields = ({ return } + if (field.type === 'text' && field.hasMany) { + const textPath = `${path || ''}${field.name}` + + if (field.localized) { + if (typeof fieldData === 'object') { + Object.entries(fieldData).forEach(([localeKey, localeData]) => { + if (Array.isArray(localeData)) { + transformTexts({ + baseRow: { + locale: localeKey, + path: textPath, + }, + data: localeData, + texts, + }) + } + }) + } + } else if (Array.isArray(fieldData)) { + transformTexts({ + baseRow: { + path: textPath, + }, + data: fieldData, + texts, + }) + } + + return + } + if (field.type === 'number' && field.hasMany) { const numberPath = `${path || ''}${field.name}` diff --git a/packages/db-postgres/src/transform/write/types.ts b/packages/db-postgres/src/transform/write/types.ts index 794ea6472d..361a7ccf82 100644 --- a/packages/db-postgres/src/transform/write/types.ts +++ b/packages/db-postgres/src/transform/write/types.ts @@ -34,6 +34,7 @@ export type RowToInsert = { locales: { [locale: string]: Record } + texts: Record[] numbers: Record[] relationships: Record[] relationshipsToDelete: RelationshipToDelete[] diff --git a/packages/db-postgres/src/upsertRow/index.ts b/packages/db-postgres/src/upsertRow/index.ts index 1482818e29..3ed311288e 100644 --- a/packages/db-postgres/src/upsertRow/index.ts +++ b/packages/db-postgres/src/upsertRow/index.ts @@ -68,6 +68,7 @@ export const upsertRow = async ({ const localesToInsert: Record[] = [] const relationsToInsert: Record[] = [] + const textsToInsert: Record[] = [] const numbersToInsert: Record[] = [] const blocksToInsert: { [blockType: string]: BlockRowToInsert[] } = {} const selectsToInsert: { [selectTableName: string]: Record[] } = {} @@ -89,6 +90,14 @@ export const upsertRow = async ({ }) } + // If there are texts, add parent to each + if (rowToInsert.texts.length > 0) { + rowToInsert.texts.forEach((textRow) => { + textRow.parent = insertedRow.id + textsToInsert.push(textRow) + }) + } + // If there are numbers, add parent to each if (rowToInsert.numbers.length > 0) { rowToInsert.numbers.forEach((numberRow) => { @@ -161,6 +170,29 @@ export const upsertRow = async ({ await db.insert(adapter.tables[relationshipsTableName]).values(relationsToInsert) } + // ////////////////////////////////// + // INSERT hasMany TEXTS + // ////////////////////////////////// + + const textsTableName = `${tableName}_texts` + + if (operation === 'update') { + await deleteExistingRowsByPath({ + adapter, + db, + localeColumnName: 'locale', + parentColumnName: 'parent', + parentID: insertedRow.id, + pathColumnName: 'path', + rows: textsToInsert, + tableName: textsTableName, + }) + } + + if (textsToInsert.length > 0) { + await db.insert(adapter.tables[textsTableName]).values(textsToInsert).returning() + } + // ////////////////////////////////// // INSERT hasMany NUMBERS // ////////////////////////////////// diff --git a/packages/payload/src/admin/components/forms/field-types/Text/Input.tsx b/packages/payload/src/admin/components/forms/field-types/Text/Input.tsx index 68f8ff5ffe..90ec699838 100644 --- a/packages/payload/src/admin/components/forms/field-types/Text/Input.tsx +++ b/packages/payload/src/admin/components/forms/field-types/Text/Input.tsx @@ -4,9 +4,11 @@ import React from 'react' import { useTranslation } from 'react-i18next' import type { TextField } from '../../../../../fields/config/types' +import type { Option } from '../../../elements/ReactSelect/types' import type { Description } from '../../FieldDescription/types' import { getTranslation } from '../../../../../utilities/getTranslation' +import ReactSelect from '../../../elements/ReactSelect' import DefaultError from '../../Error' import FieldDescription from '../../FieldDescription' import DefaultLabel from '../../Label' @@ -21,6 +23,7 @@ export type TextInputProps = Omit & { className?: string description?: Description errorMessage?: string + hasMany?: boolean inputRef?: React.MutableRefObject onChange?: (e: ChangeEvent) => void onKeyDown?: React.KeyboardEventHandler @@ -32,6 +35,7 @@ export type TextInputProps = Omit & { showError?: boolean style?: React.CSSProperties value?: string + valueToRender?: Option[] width?: string } @@ -44,8 +48,11 @@ const TextInput: React.FC = (props) => { className, description, errorMessage, + hasMany, inputRef, label, + maxRows, + minRows, onChange, onKeyDown, path, @@ -56,17 +63,25 @@ const TextInput: React.FC = (props) => { showError, style, value, + valueToRender, width, } = props - const { i18n } = useTranslation() + const { i18n, t } = useTranslation() const ErrorComp = Error || DefaultError const LabelComp = Label || DefaultLabel return (
= (props) => {
{Array.isArray(beforeInput) && beforeInput.map((Component, i) => )} - + {hasMany ? ( + { + const isOverHasMany = Array.isArray(value) && value.length >= maxRows + return !isOverHasMany + }} + isClearable + isCreatable + isMulti + isSortable + noOptionsMessage={({ inputValue }) => { + const isOverHasMany = Array.isArray(value) && value.length >= maxRows + if (isOverHasMany) { + return t('validation:limitReached', { max: maxRows, value: value.length + 1 }) + } + return t('general:noOptions') + }} + onChange={onChange} + options={[]} + placeholder={t('general:enterAValue')} + showError={showError} + value={valueToRender} + /> + ) : ( + + )} {Array.isArray(afterInput) && afterInput.map((Component, i) => )}
= (props) => { style, width, } = {}, + hasMany, inputRef, label, localized, maxLength, + maxRows, minLength, + minRows, path: pathFromProps, required, validate = text, @@ -58,6 +61,50 @@ const Text: React.FC = (props) => { validate: memoizedValidate, }) + const handleOnChange = (e) => { + setValue(e.target.value) + } + + const handleHasManyChange = useCallback( + (selectedOption) => { + if (!readOnly) { + let newValue + if (!selectedOption) { + newValue = [] + } else if (Array.isArray(selectedOption)) { + newValue = selectedOption.map((option) => option.value?.value || option.value) + } else { + newValue = [selectedOption.value?.value || selectedOption.value] + } + + setValue(newValue) + } + }, + [readOnly, setValue], + ) + + const [valueToRender, setValueToRender] = useState< + { id: string; label: string; value: { value: string } }[] + >([]) // Only for hasMany + + // useeffect update valueToRender: + useEffect(() => { + if (hasMany && Array.isArray(value)) { + setValueToRender( + value.map((val, index) => { + return { + id: `${val}${index}`, // append index to avoid duplicate keys but allow duplicate numbers + label: `${val}`, + value: { + toString: () => `${val}${index}`, + value: val?.value || val, + }, // You're probably wondering, why the hell is this done that way? Well, React-select automatically uses "label-value" as a key, so we will get that react duplicate key warning if we just pass in the value as multiple values can be the same. So we need to append the index to the toString() of the value to avoid that warning, as it uses that as the key. + } + }), + ) + } + }, [value, hasMany]) + return ( = (props) => { className={className} description={description} errorMessage={errorMessage} + hasMany={hasMany} inputRef={inputRef} label={label} + maxRows={maxRows} + minRows={minRows} name={name} - onChange={(e) => { - setValue(e.target.value) - }} + onChange={hasMany ? handleHasManyChange : handleOnChange} path={path} placeholder={placeholder} readOnly={readOnly} @@ -81,6 +129,7 @@ const Text: React.FC = (props) => { showError={showError} style={style} value={value} + valueToRender={valueToRender} width={width} /> ) diff --git a/packages/payload/src/fields/config/schema.ts b/packages/payload/src/fields/config/schema.ts index def5421192..9f1b84d679 100644 --- a/packages/payload/src/fields/config/schema.ts +++ b/packages/payload/src/fields/config/schema.ts @@ -83,8 +83,11 @@ export const text = baseField.keys({ rtl: joi.boolean(), }), defaultValue: joi.alternatives().try(joi.string(), joi.func()), + hasMany: joi.boolean().default(false), maxLength: joi.number(), + maxRows: joi.number().when('hasMany', { is: joi.not(true), then: joi.forbidden() }), minLength: joi.number(), + minRows: joi.number().when('hasMany', { is: joi.not(true), then: joi.forbidden() }), type: joi.string().valid('text').required(), }) diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 3c518a1613..4ab76110ab 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -226,7 +226,24 @@ export type TextField = FieldBase & { maxLength?: number minLength?: number type: 'text' -} +} & ( + | { + /** Makes this field an ordered array of strings instead of just a single string. */ + hasMany: true + /** Maximum number of strings in the strings array, if `hasMany` is set to true. */ + maxRows?: number + /** Minimum number of strings in the strings array, if `hasMany` is set to true. */ + minRows?: number + } + | { + /** Makes this field an ordered array of strings instead of just a single string. */ + hasMany?: false | undefined + /** Maximum number of strings in the strings array, if `hasMany` is set to true. */ + maxRows?: undefined + /** Minimum number of strings in the strings array, if `hasMany` is set to true. */ + minRows?: undefined + } + ) export type EmailField = FieldBase & { admin?: Admin & { diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts index 869aab31cb..168f2e33cf 100644 --- a/packages/payload/src/fields/validations.ts +++ b/packages/payload/src/fields/validations.ts @@ -27,19 +27,35 @@ import { isValidID } from '../utilities/isValidID' import { fieldAffectsData } from './config/types' export const text: Validate = ( - value: string, - { config, maxLength: fieldMaxLength, minLength, required, t }, + value: string | string[], + { config, hasMany, maxLength: fieldMaxLength, maxRows, minLength, minRows, required, t }, ) => { let maxLength: number - if (typeof config?.defaultMaxTextLength === 'number') maxLength = config.defaultMaxTextLength - if (typeof fieldMaxLength === 'number') maxLength = fieldMaxLength - if (value && maxLength && value.length > maxLength) { - return t('validation:shorterThanMax', { maxLength }) + if (!required) { + if (!value) return true } - if (value && minLength && value?.length < minLength) { - return t('validation:longerThanMin', { minLength }) + if (hasMany === true) { + const lengthValidationResult = validateArrayLength(value, { maxRows, minRows, required, t }) + if (typeof lengthValidationResult === 'string') return lengthValidationResult + } + + if (typeof config?.defaultMaxTextLength === 'number') maxLength = config.defaultMaxTextLength + if (typeof fieldMaxLength === 'number') maxLength = fieldMaxLength + + const stringsToValidate: string[] = Array.isArray(value) ? value : [value] + + for (const stringValue of stringsToValidate) { + const length = stringValue?.length || 0 + + if (typeof maxLength === 'number' && length > maxLength) { + return t('validation:shorterThanMax', { label: t('value'), maxLength, stringValue }) + } + + if (typeof minLength === 'number' && length < minLength) { + return t('validation:longerThanMin', { label: t('value'), minLength, stringValue }) + } } if (required) { diff --git a/packages/payload/src/graphql/schema/buildMutationInputType.ts b/packages/payload/src/graphql/schema/buildMutationInputType.ts index fbdf216e78..dd3d6ab4b0 100644 --- a/packages/payload/src/graphql/schema/buildMutationInputType.ts +++ b/packages/payload/src/graphql/schema/buildMutationInputType.ts @@ -268,7 +268,13 @@ function buildMutationInputType( }, text: (inputObjectTypeConfig: InputObjectTypeConfig, field: TextField) => ({ ...inputObjectTypeConfig, - [field.name]: { type: withNullableType(field, GraphQLString, forceNullable) }, + [field.name]: { + type: withNullableType( + field, + field.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString, + forceNullable, + ), + }, }), textarea: (inputObjectTypeConfig: InputObjectTypeConfig, field: TextareaField) => ({ ...inputObjectTypeConfig, diff --git a/packages/payload/src/graphql/schema/buildObjectType.ts b/packages/payload/src/graphql/schema/buildObjectType.ts index ec194149f8..826829ee02 100644 --- a/packages/payload/src/graphql/schema/buildObjectType.ts +++ b/packages/payload/src/graphql/schema/buildObjectType.ts @@ -543,7 +543,13 @@ function buildObjectType({ }, objectTypeConfig), text: (objectTypeConfig: ObjectTypeConfig, field: TextField) => ({ ...objectTypeConfig, - [field.name]: { type: withNullableType(field, GraphQLString, forceNullable) }, + [field.name]: { + type: withNullableType( + field, + field.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString, + forceNullable, + ), + }, }), textarea: (objectTypeConfig: ObjectTypeConfig, field: TextareaField) => ({ ...objectTypeConfig, diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index ad685d2cf9..5680724ee2 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -99,6 +99,15 @@ function fieldsToJSONSchema( let fieldSchema: JSONSchema4 switch (field.type) { case 'text': + if (field.hasMany === true) { + fieldSchema = { + items: { type: 'string' }, + type: withNullableJSONSchemaType('array', isRequired), + } + } else { + fieldSchema = { type: withNullableJSONSchemaType('string', isRequired) } + } + break case 'textarea': case 'code': case 'email': diff --git a/test/fields/collections/Text/index.ts b/test/fields/collections/Text/index.ts index 227b611669..6038b20c77 100644 --- a/test/fields/collections/Text/index.ts +++ b/test/fields/collections/Text/index.ts @@ -111,6 +111,35 @@ const TextFields: CollectionConfig = { }, type: 'text', }, + { + name: 'hasMany', + type: 'text', + hasMany: true, + }, + { + name: 'validatesHasMany', + type: 'text', + hasMany: true, + minLength: 3, + }, + { + name: 'localizedHasMany', + type: 'text', + hasMany: true, + localized: true, + }, + { + name: 'withMinRows', + type: 'text', + hasMany: true, + minRows: 2, + }, + { + name: 'withMaxRows', + type: 'text', + hasMany: true, + maxRows: 4, + }, ], slug: textFieldsSlug, } diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index 59911a77b3..c6518ff517 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -119,6 +119,25 @@ describe('fields', () => { const nextSiblingText = await page.evaluate((el) => el.textContent, nextSibling) expect(nextSiblingText).toEqual('#after-input') }) + + test('should create hasMany with multiple texts', async () => { + const input = 'five' + const furtherInput = 'six' + + await page.goto(url.create) + const requiredField = page.locator('#field-text') + const field = page.locator('.field-hasMany') + + await requiredField.fill(String(input)) + await field.click() + await page.keyboard.type(input) + await page.keyboard.press('Enter') + await page.keyboard.type(furtherInput) + await page.keyboard.press('Enter') + await saveDocAndAssert(page) + await expect(field.locator('.rs__value-container')).toContainText(input) + await expect(field.locator('.rs__value-container')).toContainText(furtherInput) + }) }) describe('number', () => { diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index cdde2aae84..f905f79c9c 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -95,6 +95,27 @@ describe('Fields', () => { await expect(fieldWithDefaultValue).toEqual(dependentOnFieldWithDefaultValue) }) + + it('should localize an array of strings using hasMany', async () => { + const localizedHasMany = ['hello', 'world'] + const { id } = await payload.create({ + collection: 'text-fields', + data: { + text, + localizedHasMany, + }, + locale: 'en', + }) + const localizedDoc = await payload.findByID({ + id, + collection: 'text-fields', + locale: 'all', + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(localizedDoc.localizedHasMany.en).toEqual(localizedHasMany) + }) }) describe('relationship', () => { diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 186fbd9de6..255be8a9ed 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -94,6 +94,21 @@ export interface LexicalMigrateField { } [k: string]: unknown } | null + lexicalWithSlateData?: { + root: { + children: { + type: string + version: number + [k: string]: unknown + }[] + direction: ('ltr' | 'rtl') | null + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '' + indent: number + type: string + version: number + } + [k: string]: unknown + } | null lexicalSimple?: { root: { children: { @@ -426,6 +441,35 @@ export interface BlockField { } )[] | null + relationshipBlocks?: + | { + relationship?: (string | null) | TextField + id?: string | null + blockName?: string | null + blockType: 'relationships' + }[] + | null + updatedAt: string + createdAt: string +} +export interface TextField { + id: string + text: string + localizedText?: string | null + i18nText?: string | null + defaultFunction?: string | null + defaultAsync?: string | null + overrideLength?: string | null + fieldWithDefaultValue?: string | null + dependentOnFieldWithDefaultValue?: string | null + customLabel?: string | null + customError?: string | null + beforeAndAfterInput?: string | null + hasMany?: string[] | null + validatesHasMany?: string[] | null + localizedHasMany?: string[] | null + withMinRows?: string[] | null + withMaxRows?: string[] | null updatedAt: string createdAt: string } @@ -692,22 +736,6 @@ export interface RelationshipField { updatedAt: string createdAt: string } -export interface TextField { - id: string - text: string - localizedText?: string | null - i18nText?: string | null - defaultFunction?: string | null - defaultAsync?: string | null - overrideLength?: string | null - fieldWithDefaultValue?: string | null - dependentOnFieldWithDefaultValue?: string | null - customLabel?: string | null - customError?: string | null - beforeAndAfterInput?: string | null - updatedAt: string - createdAt: string -} export interface RichTextField { id: string title: string