diff --git a/docs/fields/array.mdx b/docs/fields/array.mdx index eeb2769652..4e46406bad 100644 --- a/docs/fields/array.mdx +++ b/docs/fields/array.mdx @@ -66,7 +66,7 @@ _\* An asterisk denotes that a property is required._ ## Admin Options -The customize the appearance and behavior of the Array Field in the [Admin Panel](../admin/overview), you can use the `admin` option: +To customize the appearance and behavior of the Array Field in the [Admin Panel](../admin/overview), you can use the `admin` option: ```ts import type { Field } from 'payload/types' @@ -81,11 +81,11 @@ export const MyArrayField: Field = { The Array Field inherits all of the default options from the base [Field Admin Config](../admin/fields#admin-options), plus the following additional options: -| Option | Description | -| ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| **`initCollapsed`** | Set the initial collapsed state | -| **`components.RowLabel`** | Function or React component to be rendered as the label on the array row. Receives `({ data, index, path })` as args | -| **`isSortable`** | Disable order sorting by setting this value to `false` | +| Option | Description | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| **`initCollapsed`** | Set the initial collapsed state | +| **`components.RowLabel`** | React component to be rendered as the label on the array row. [Example](#example-of-a-custom-rowlabel-component) | +| **`isSortable`** | Disable order sorting by setting this value to `false` | ## Example @@ -127,12 +127,27 @@ export const ExampleCollection: CollectionConfig = { ], admin: { components: { - RowLabel: ({ data, index }) => { - return data?.title || `Slide ${String(index).padStart(2, '0')}` - }, + RowLabel: '/path/to/ArrayRowLabel#ArrayRowLabel', }, }, }, ], } ``` + +### Example of a custom RowLabel component + + +```tsx +'use client' + +import { useRowLabel } from '@payloadcms/ui' + +export const ArrayRowLabel = () => { + const { data, rowNumber } = useRowLabel<{ title?: string }>() + + const customLabel = `${data.title || 'Slide'} ${String(rowNumber).padStart(2, '0')} ` + + return
Custom Label: {customLabel}
+} +``` diff --git a/eslint.config.js b/eslint.config.js index 038f4347f8..ae45e1d639 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -53,6 +53,7 @@ export const rootEslintConfig = [ 'payload/no-relative-monorepo-imports': 'error', 'payload/no-imports-from-exports-dir': 'error', 'payload/no-imports-from-self': 'error', + 'payload/proper-payload-logger-usage': 'error', }, }, { diff --git a/packages/eslint-plugin/customRules/proper-payload-logger-usage.js b/packages/eslint-plugin/customRules/proper-payload-logger-usage.js new file mode 100644 index 0000000000..01817687f9 --- /dev/null +++ b/packages/eslint-plugin/customRules/proper-payload-logger-usage.js @@ -0,0 +1,89 @@ +export const rule = { + meta: { + type: 'problem', + docs: { + description: 'Disallow improper usage of payload.logger.error', + recommended: 'error', + }, + messages: { + improperUsage: 'Improper logger usage. Pass { msg, err } so full error stack is logged.', + wrongErrorField: 'Improper usage. Use { err } instead of { error }.', + wrongMessageField: 'Improper usage. Use { msg } instead of { message }.', + }, + schema: [], + }, + create(context) { + return { + CallExpression(node) { + const callee = node.callee + + // Function to check if the expression ends with `payload.logger.error` + function isPayloadLoggerError(expression) { + return ( + expression.type === 'MemberExpression' && + expression.property.name === 'error' && // must be `.error` + expression.object.type === 'MemberExpression' && + expression.object.property.name === 'logger' && // must be `.logger` + (expression.object.object.name === 'payload' || // handles just `payload` + (expression.object.object.type === 'MemberExpression' && + expression.object.object.property.name === 'payload')) // handles `*.payload` + ) + } + + // Check if the function being called is `payload.logger.error` or `*.payload.logger.error` + if (isPayloadLoggerError(callee)) { + const args = node.arguments + + // Case 1: Single string is passed as the argument + if ( + args.length === 1 && + args[0].type === 'Literal' && + typeof args[0].value === 'string' + ) { + return // Valid: single string argument + } + + // Case 2: Object is passed as the first argument + if (args.length > 0 && args[0].type === 'ObjectExpression') { + const properties = args[0].properties + + // Ensure no { error } key, only { err } is allowed + properties.forEach((prop) => { + if (prop.key.type === 'Identifier' && prop.key.name === 'error') { + context.report({ + node: prop, + messageId: 'wrongErrorField', + }) + } + + // Ensure no { message } key, only { msg } is allowed + if (prop.key.type === 'Identifier' && prop.key.name === 'message') { + context.report({ + node: prop, + messageId: 'wrongMessageField', + }) + } + }) + return // Valid object, checked for 'err'/'error' keys + } + + // Case 3: Improper usage (string + error or additional err/error) + if ( + args.length > 1 && + args[0].type === 'Literal' && + typeof args[0].value === 'string' && + args[1].type === 'Identifier' && + (args[1].name === 'err' || args[1].name === 'error') + ) { + context.report({ + node, + messageId: 'improperUsage', + }) + } + } + }, + } + }, +} + +export default rule diff --git a/packages/eslint-plugin/index.mjs b/packages/eslint-plugin/index.mjs index dd743b0773..c2c8a03916 100644 --- a/packages/eslint-plugin/index.mjs +++ b/packages/eslint-plugin/index.mjs @@ -4,6 +4,7 @@ import noRelativeMonorepoImports from './customRules/no-relative-monorepo-import import noImportsFromExportsDir from './customRules/no-imports-from-exports-dir.js' import noFlakyAssertions from './customRules/no-flaky-assertions.js' import noImportsFromSelf from './customRules/no-imports-from-self.js' +import properPinoLoggerErrorUsage from './customRules/proper-payload-logger-usage.js' /** * @type {import('eslint').ESLint.Plugin} @@ -11,10 +12,13 @@ import noImportsFromSelf from './customRules/no-imports-from-self.js' const index = { rules: { 'no-jsx-import-statements': noJsxImportStatements, - 'no-non-retryable-assertions': noNonRetryableAssertions, 'no-relative-monorepo-imports': noRelativeMonorepoImports, 'no-imports-from-exports-dir': noImportsFromExportsDir, 'no-imports-from-self': noImportsFromSelf, + 'proper-payload-logger-usage': properPinoLoggerErrorUsage, + + // Testing-related + 'no-non-retryable-assertions': noNonRetryableAssertions, 'no-flaky-assertions': noFlakyAssertions, 'no-wait-function': { create: function (context) { diff --git a/packages/eslint-plugin/tests/proper-payload-logger-usage.js b/packages/eslint-plugin/tests/proper-payload-logger-usage.js new file mode 100644 index 0000000000..1eccd5123f --- /dev/null +++ b/packages/eslint-plugin/tests/proper-payload-logger-usage.js @@ -0,0 +1,72 @@ +import { RuleTester } from 'eslint' +import rule from '../customRules/proper-payload-logger-usage.js' + +const ruleTester = new RuleTester() + +// Example tests for the rule +ruleTester.run('no-improper-payload-logger-error', rule, { + valid: [ + // Valid: payload.logger.error with object containing { msg, err } + { + code: "payload.logger.error({ msg: 'some message', err })", + }, + // Valid: payload.logger.error with a single string + { + code: "payload.logger.error('Some error message')", + }, + // Valid: *.payload.logger.error with object + { + code: "this.payload.logger.error({ msg: 'another message', err })", + }, + { + code: "args.req.payload.logger.error({ msg: 'different message', err })", + }, + ], + + invalid: [ + // Invalid: payload.logger.error with both string and error + { + code: "payload.logger.error('Some error message', err)", + errors: [ + { + messageId: 'improperUsage', + }, + ], + }, + // Invalid: *.payload.logger.error with both string and error + { + code: "this.payload.logger.error('Some error message', error)", + errors: [ + { + messageId: 'improperUsage', + }, + ], + }, + { + code: "args.req.payload.logger.error('Some error message', err)", + errors: [ + { + messageId: 'improperUsage', + }, + ], + }, + // Invalid: payload.logger.error with object containing 'message' key + { + code: "payload.logger.error({ message: 'not the right property name' })", + errors: [ + { + messageId: 'wrongMessageField', + }, + ], + }, + // Invalid: *.payload.logger.error with object containing 'error' key + { + code: "this.payload.logger.error({ msg: 'another message', error })", + errors: [ + { + messageId: 'wrongErrorField', + }, + ], + }, + ], +}) diff --git a/packages/plugin-seo/src/translations/de.ts b/packages/plugin-seo/src/translations/de.ts new file mode 100644 index 0000000000..07c738e05f --- /dev/null +++ b/packages/plugin-seo/src/translations/de.ts @@ -0,0 +1,28 @@ +import type { GenericTranslationsObject } from '@payloadcms/translations' + +export const de: GenericTranslationsObject = { + $schema: './translation-schema.json', + 'plugin-seo': { + almostThere: 'Fast da', + autoGenerate: 'Automatisch generieren', + bestPractices: 'Best Practices', + characterCount: '{{current}}/{{minLength}}-{{maxLength}} Zeichen, ', + charactersLeftOver: '{{characters}} verbleiben', + charactersToGo: '{{characters}} übrig', + charactersTooMany: '{{characters}} zu viel', + checksPassing: '{{current}}/{{max}} Kontrollen erfolgreich', + good: 'Gut', + imageAutoGenerationTip: 'Die automatische Generierung ruft das ausgewählte Hauptbild ab.', + lengthTipDescription: + 'Diese sollte zwischen {{minLength}} und {{maxLength}} Zeichen lang sein. Für Hilfe beim Schreiben von qualitativ hochwertigen Meta-Beschreibungen siehe ', + lengthTipTitle: + 'Dieser sollte zwischen {{minLength}} und {{maxLength}} Zeichen lang sein. Für Hilfe beim Schreiben von qualitativ hochwertigen Meta-Titeln siehe ', + missing: 'Fehlt', + noImage: 'Kein Bild', + preview: 'Vorschau', + previewDescription: + 'Die genauen Ergebnislisten können je nach Inhalt und Suchrelevanz variieren.', + tooLong: 'Zu lang', + tooShort: 'Zu kurz', + }, +} diff --git a/packages/plugin-seo/src/translations/index.ts b/packages/plugin-seo/src/translations/index.ts index 3774a56d66..80a0fdebfa 100644 --- a/packages/plugin-seo/src/translations/index.ts +++ b/packages/plugin-seo/src/translations/index.ts @@ -1,5 +1,6 @@ import type { GenericTranslationsObject, NestedKeysStripped } from '@payloadcms/translations' +import { de } from './de.js' import { en } from './en.js' import { es } from './es.js' import { fa } from './fa.js' @@ -11,6 +12,7 @@ import { ru } from './ru.js' import { uk } from './uk.js' export const translations = { + de, en, es, fa, diff --git a/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx b/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx index 4750e60f15..b218bc77cb 100644 --- a/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx +++ b/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx @@ -124,7 +124,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R } else { // internal link setLinkUrl( - `/admin/collections/${focusLinkParent.getFields()?.doc?.relationTo}/${ + `${config.routes.admin === '/' ? '' : config.routes.admin}/collections/${focusLinkParent.getFields()?.doc?.relationTo}/${ focusLinkParent.getFields()?.doc?.value }`, ) diff --git a/scripts/utils/updateChangelog.ts b/scripts/utils/updateChangelog.ts index 371ba5e9b1..bc89e5a232 100755 --- a/scripts/utils/updateChangelog.ts +++ b/scripts/utils/updateChangelog.ts @@ -62,41 +62,53 @@ export const updateChangelog = async (args: Args = {}): Promise const conventionalCommits = await getLatestCommits(fromVersion, toVersion) - const sections: Record<'breaking' | 'feat' | 'fix' | 'perf', string[]> = { - feat: [], - fix: [], - perf: [], - breaking: [], - } + type SectionKey = 'breaking' | 'feat' | 'fix' | 'perf' - // Group commits by type - conventionalCommits.forEach((c) => { - if (c.isBreaking) { - sections.breaking.push(formatCommitForChangelog(c, true)) - } + const sections = conventionalCommits.reduce( + (sections, c) => { + if (c.isBreaking) { + sections.breaking.push(c) + } - if (c.type === 'feat' || c.type === 'fix' || c.type === 'perf') { - sections[c.type].push(formatCommitForChangelog(c)) - } + if (['feat', 'fix', 'perf'].includes(c.type)) { + sections[c.type].push(c) + } + return sections + }, + { feat: [], fix: [], perf: [], breaking: [] } as Record, + ) + + // Sort commits by scope, unscoped first + Object.values(sections).forEach((section) => { + section.sort((a, b) => (a.scope || '').localeCompare(b.scope || '')) }) + const stringifiedSections = Object.fromEntries( + Object.entries(sections).map(([key, commits]) => [ + key, + commits.map((commit) => formatCommitForChangelog(commit, key === 'breaking')), + ]), + ) + // Fetch commits for fromVersion to toVersion const contributors = await createContributorSection(conventionalCommits) const yyyyMMdd = new Date().toISOString().split('T')[0] // Might need to swap out HEAD for the new proposed version let changelog = `## [${proposedReleaseVersion}](https://github.com/payloadcms/payload/compare/${fromVersion}...${proposedReleaseVersion}) (${yyyyMMdd})\n\n\n` - if (sections.feat.length) { - changelog += `### 🚀 Features\n\n${sections.feat.join('\n')}\n\n` + + // Add section headers + if (stringifiedSections.feat.length) { + changelog += `### 🚀 Features\n\n${stringifiedSections.feat.join('\n')}\n\n` } - if (sections.perf.length) { - changelog += `### ⚡ Performance\n\n${sections.perf.join('\n')}\n\n` + if (stringifiedSections.perf.length) { + changelog += `### ⚡ Performance\n\n${stringifiedSections.perf.join('\n')}\n\n` } - if (sections.fix.length) { - changelog += `### 🐛 Bug Fixes\n\n${sections.fix.join('\n')}\n\n` + if (stringifiedSections.fix.length) { + changelog += `### 🐛 Bug Fixes\n\n${stringifiedSections.fix.join('\n')}\n\n` } - if (sections.breaking.length) { - changelog += `### ⚠️ BREAKING CHANGES\n\n${sections.breaking.join('\n')}\n\n` + if (stringifiedSections.breaking.length) { + changelog += `### ⚠️ BREAKING CHANGES\n\n${stringifiedSections.breaking.join('\n')}\n\n` } if (writeChangelog) { @@ -200,11 +212,13 @@ function formatCommitForChangelog(commit: GitCommit, includeBreakingNotes = fals if (isBreaking && includeBreakingNotes) { // Parse breaking change notes from commit body const [rawNotes, _] = commit.body.split('\n\n') - let notes = rawNotes - .split('\n') - .map((l) => ` ${l}`) // Indent notes - .join('\n') - .trim() + let notes = + ` ` + + rawNotes + .split('\n') + .map((l) => ` ${l}`) // Indent notes + .join('\n') + .trim() // Remove random trailing quotes that sometimes appear if (notes.endsWith('"')) {