Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
239f400
feat(payload): add versionInSettingsMenu config + dependencies on san…
denolfe May 6, 2026
557343f
feat(payload): compute admin.packageVersions and auto-inject version …
denolfe May 6, 2026
3ca3d5c
feat(ui): add formatForClipboard helper for payload version export
denolfe May 6, 2026
1f10bb7
feat(ui): add PayloadVersionMenuItem with version drawer and copy button
denolfe May 6, 2026
20dd5b3
test(admin): cover version menu auto-injection and opt-out
denolfe May 6, 2026
e427ebe
test(admin): e2e for payload version drawer and clipboard copy
denolfe May 6, 2026
f8f49cf
fix(payload): use @payloadcms/ui/rsc subpath for PayloadVersionMenuIt…
denolfe May 6, 2026
0295779
feat(ui): use Modal instead of Drawer for PayloadVersionMenuItem
denolfe May 6, 2026
f066191
style(ui): lighter menu-item text and remove version list scroll
denolfe May 6, 2026
56fb4d8
refactor(ui): use existing CopyToClipboard component for version export
denolfe May 6, 2026
f916efb
style(ui): inline copy button with version info header
denolfe May 6, 2026
792c2b5
refactor(ui): migrate PayloadVersionMenuItem styles to vanilla CSS wi…
denolfe May 6, 2026
2dc85a0
fix(ui): use top-level BEM selectors instead of unsupported native CS…
denolfe May 6, 2026
d26bad5
style(ui): unbold package name/version rows
denolfe May 6, 2026
458ab5c
feat(payload): strip subpath probes from displayed package names
denolfe May 6, 2026
a292d48
docs: add learnings on /rsc subpath, vanilla CSS, and translation key…
denolfe May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ Generate types for a test directory: `pnpm run dev:generate-types <directory_nam
- Add new strings to English locale first, then translate to other languages
- Run `pnpm run translateNewKeys` to auto-translate new keys (requires `OPENAI_KEY` in `.env`)
- Lexical translations: `cd packages/richtext-lexical && pnpm run translateNewKeys`
- **Adding a key to `en.ts` makes it required in every other language file.** The build fails (TS2741) for each unpopulated locale until either `translateNewKeys` runs or English fallbacks are stubbed across all ~44 files. If `OPENAI_KEY` isn't available and the work is small, prefer hardcoding the user-facing string and deferring the translation key to a follow-up.

## Commit & PR Guidelines

Expand Down Expand Up @@ -356,4 +357,39 @@ import { MyClientComponent } from './MyComponent.js'
import { MyClientComponent } from '../../exports/client/index.js'
```

**3. Server-only components in built-in path strings must use the `/rsc` subpath:**

When auto-injecting a built-in server component into a config slot via path-string syntax (e.g. inside `sanitizeConfig`, `inject*` helpers), use the `/rsc` subpath. The bare `@payloadcms/ui` resolves to the client bundle, which doesn't export server-only components — the runtime warns `getFromImportMap: PayloadComponent not found in importMap`.

```typescript
// BAD - resolves to the client bundle, server-only export is missing
config.admin.components.settingsMenu.push('@payloadcms/ui#PayloadVersionMenuItem')

// GOOD - resolves through the RSC export map
config.admin.components.settingsMenu.push('@payloadcms/ui/rsc#PayloadVersionMenuItem')
```

See `packages/payload/src/hierarchy/injectHierarchyButton.ts` for the established pattern.

**Testing bundling changes:** Always test with `pnpm prepare-run-test-against-prod` followed by `pnpm dev:prod <suite>`. Dev mode (`pnpm dev`) doesn't catch these issues.

### Styling

New components prefer vanilla CSS (`index.css`) over SCSS, using v4 design tokens. Existing SCSS files are migrated as touched.

- Wrap rules in `@layer payload-default { ... }` (matches Tooltip, PopupButtonList, and other v4 components).
- Use v4 tokens: `--bg-default-default`, `--text-default-secondary`, `--spacer-N`, `--radius-medium`, `--font-family-mono`, etc. Do not hardcode colors or pixels.
- **Native CSS nesting does not support BEM concatenation.** `&__wrapper` only works in SCSS. In vanilla CSS, write each BEM child as a top-level rule within the layer:

```css
/* BAD - native CSS nesting cannot concat suffixes */
.thing {
&__row { ... }
}

/* GOOD - separate top-level rules */
.thing { ... }
.thing__row { ... }
```

- `&` is still valid for pseudo-classes/states (`&:hover`, `&.is-open`) and descendants (`& .child`).
10 changes: 10 additions & 0 deletions packages/payload/src/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { validateTimezones } from '../utilities/validateTimezones.js'
import { getSchedulePublishTask } from '../versions/schedule/job.js'
import { addDefaultsToConfig } from './defaults.js'
import { addOrderableEndpoint, addOrderableFieldsAndHook } from './orderable/index.js'
import { sanitizeDependencies } from './sanitizeDependencies.js'

const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig> => {
const sanitizedConfig = { ...configToSanitize }
Expand Down Expand Up @@ -118,6 +119,15 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC

const config: Partial<SanitizedConfig> = sanitizeAdminConfig(configWithDefaults)

config.admin!.packageVersions = await sanitizeDependencies()

if (config.admin!.versionInSettingsMenu !== false) {
const adminComponents = config.admin!.components ?? {}
config.admin!.components = adminComponents
const existing = adminComponents.settingsMenu ?? []
adminComponents.settingsMenu = [...existing, '@payloadcms/ui/rsc#PayloadVersionMenuItem']
}

if (!config.endpoints) {
config.endpoints = []
}
Expand Down
29 changes: 29 additions & 0 deletions packages/payload/src/config/sanitizeDependencies.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest'

import { sanitizeDependencies } from './sanitizeDependencies.js'

describe('sanitizeDependencies', () => {
it('returns a record always containing a payload entry', async () => {
const deps = await sanitizeDependencies()
expect(deps.payload).toMatch(/^\d+\.\d+\.\d+/)
})

it('returns alphabetically sorted keys', async () => {
const deps = await sanitizeDependencies()
const keys = Object.keys(deps)
const sorted = [...keys].sort((a, b) => a.localeCompare(b))
expect(keys).toEqual(sorted)
})

it('falls back to a payload-only map when getDependencies throws', async () => {
vi.doMock('../utilities/dependencies/getDependencies.js', () => ({
getDependencies: () => Promise.reject(new Error('boom')),
}))
vi.resetModules()
const { sanitizeDependencies: freshSanitize } = await import('./sanitizeDependencies.js')
const deps = await freshSanitize()
expect(Object.keys(deps)).toEqual(['payload'])
expect(deps.payload).toMatch(/^\d+\.\d+\.\d+/)
vi.doUnmock('../utilities/dependencies/getDependencies.js')
})
})
61 changes: 61 additions & 0 deletions packages/payload/src/config/sanitizeDependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { promises as fs } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

import { getDependencies } from '../utilities/dependencies/getDependencies.js'
import { PAYLOAD_PACKAGE_LIST } from '../versions/payloadPackageList.js'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const payloadPkgRoot = path.resolve(dirname, '../../')

const readPayloadFallbackVersion = async (): Promise<string> => {
try {
const pkgJsonPath = path.join(payloadPkgRoot, 'package.json')
const raw = await fs.readFile(pkgJsonPath, 'utf8')
const parsed = JSON.parse(raw) as { version?: string }
return parsed.version ?? '0.0.0'
} catch {
return '0.0.0'
}
}

/**
* Strip any subpath after the npm package name. For `@payloadcms/next/utilities`,
* the package name is `@payloadcms/next`. For `lodash/get`, it's `lodash`.
* The list passed to `getDependencies` includes subpath probes (e.g. to verify
* a specific export resolves), but for display we only want the bare package.
*/
const stripSubpath = (specifier: string): string => {
if (specifier.startsWith('@')) {
const [scope, name] = specifier.split('/')
return name ? `${scope}/${name}` : specifier
}
return specifier.split('/')[0] ?? specifier
}

/**
* Scan the user's project for installed Payload-related packages and return
* an alphabetically-sorted map of name → version. Always contains `payload`.
* Never throws — falls back to a single-entry map if the scan fails.
*/
export const sanitizeDependencies = async (): Promise<Record<string, string>> => {
try {
const result = await getDependencies(process.cwd(), ['payload', ...PAYLOAD_PACKAGE_LIST])
const map: Record<string, string> = {}
for (const [specifier, { version }] of result.resolved) {
const name = stripSubpath(specifier)
// If the same package is probed via multiple specifiers, keep the first hit.
if (!map[name]) {
map[name] = version
}
}
const sorted = Object.fromEntries(Object.entries(map).sort(([a], [b]) => a.localeCompare(b)))
if (!sorted.payload) {
sorted.payload = await readPayloadFallbackVersion()
}
return sorted
} catch {
return { payload: await readPayloadFallbackVersion() }
}
}
13 changes: 13 additions & 0 deletions packages/payload/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,13 @@ export type Config = {
}
/** The slug of a Collection that you want to be used to log in to the Admin dashboard. */
user?: string
/**
* When false, the built-in Payload version item is not auto-injected into
* `admin.components.settingsMenu`. Users can still add `@payloadcms/ui/rsc#PayloadVersionMenuItem`
* manually to control placement.
* @default true
*/
versionInSettingsMenu?: boolean
}

/**
Expand Down Expand Up @@ -1582,6 +1589,12 @@ export type Config = {
*/
export type SanitizedConfig = {
admin: {
/**
* Map of resolved Payload-related package versions (name → semver), computed
* at config sanitization. Always contains a `payload` entry. Missing packages
* are omitted. Used by the built-in version menu item.
*/
packageVersions: Record<string, string>
timezones: SanitizedTimezoneConfig
} & DeepRequired<Config['admin']>
blocks?: FlattenedBlock[]
Expand Down
75 changes: 75 additions & 0 deletions packages/payload/src/config/versionMenu.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'

import type { Config } from './types.js'

import { sanitizeConfig } from './sanitize.js'

const VERSION_MENU_ITEM = '@payloadcms/ui/rsc#PayloadVersionMenuItem'

/** Minimal config shape — db/editor are not used during the sanitization paths we test */
// @ts-expect-error
const minimalConfig: Config = {
collections: [],
}

describe('version menu auto-injection', () => {
it('populates packageVersions.payload with a semver string', async () => {
const sanitized = await sanitizeConfig(minimalConfig)

expect(sanitized.admin.packageVersions.payload).toMatch(/^\d+\.\d+\.\d+/)
})

it('appends the menu item after user-defined settingsMenu entries', async () => {
const item1 = '/components/Item1.tsx#Item1'
const item2 = '/components/Item2.tsx#Item2'

// @ts-expect-error
const config: Config = {
collections: [],
admin: {
components: {
settingsMenu: [item1, item2],
},
},
}

const sanitized = await sanitizeConfig(config)
const menu = sanitized.admin.components!.settingsMenu!

expect(menu.slice(0, 2)).toEqual([item1, item2])
expect(menu[menu.length - 1]).toBe(VERSION_MENU_ITEM)
})

it('does NOT inject the menu item when versionInSettingsMenu is false', async () => {
// @ts-expect-error
const config: Config = {
collections: [],
admin: {
versionInSettingsMenu: false,
components: {
settingsMenu: ['/components/Item1.tsx#Item1'],
},
},
}

const sanitized = await sanitizeConfig(config)
const menu = sanitized.admin.components?.settingsMenu ?? []

expect(menu).not.toContain(VERSION_MENU_ITEM)
})

it('injects the menu item when no settingsMenu is defined', async () => {
// @ts-expect-error
const config: Config = {
collections: [],
admin: {
components: {},
},
}

const sanitized = await sanitizeConfig(config)
const menu = sanitized.admin.components!.settingsMenu!

expect(menu).toContain(VERSION_MENU_ITEM)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client'

import React from 'react'

// eslint-disable-next-line payload/no-imports-from-exports-dir -- Client component imports from same package's client bundle
import { CopyToClipboard, Modal, PopupList, useModal } from '../../exports/client/index.js'
import { formatForClipboard } from './formatForClipboard.js'
import { VersionList } from './VersionList.js'
import './index.css'

const baseClass = 'payload-version-menu-item'
const modalSlug = 'payload-version-info'

export const PayloadVersionModalTrigger: React.FC<{
versions: Record<string, string>
}> = ({ versions }) => {
const { closeModal, isModalOpen, openModal } = useModal()
const payloadVersion = versions.payload ?? '0.0.0'

return (
<PopupList.ButtonGroup>
<PopupList.Button className={`${baseClass}__button`} onClick={() => openModal(modalSlug)}>
{`Payload v${payloadVersion}`}
</PopupList.Button>
{isModalOpen(modalSlug) && (
<Modal className={baseClass} closeOnBlur={false} slug={modalSlug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header`}>
<h2>Payload Version Info</h2>
<CopyToClipboard value={formatForClipboard(versions)} />
<button
aria-label="Close"
className={`${baseClass}__close`}
onClick={() => closeModal(modalSlug)}
type="button"
>
×
</button>
</div>
<VersionList versions={versions} />
</div>
</Modal>
)}
</PopupList.ButtonGroup>
)
}
25 changes: 25 additions & 0 deletions packages/ui/src/elements/PayloadVersionMenuItem/VersionList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client'

import React from 'react'

export const VersionList: React.FC<{
versions: Record<string, string>
}> = ({ versions }) => {
const ordered = [
['payload', versions.payload],
...Object.entries(versions)
.filter(([k]) => k !== 'payload')
.sort(([a], [b]) => a.localeCompare(b)),
].filter(([, v]) => Boolean(v)) as Array<[string, string]>

return (
<dl className="payload-version-menu-item__list">
{ordered.map(([name, version]) => (
<div className="payload-version-menu-item__row" key={name}>
<dt>{name}</dt>
<dd>{version}</dd>
</div>
))}
</dl>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { formatForClipboard } from './formatForClipboard.js'

describe('formatForClipboard', () => {
it('puts payload first, rest alphabetical', () => {
expect(
formatForClipboard({
'@payloadcms/ui': '3.0.0',
'@payloadcms/db-mongodb': '3.0.0',
payload: '3.0.0',
}),
).toBe('payload: 3.0.0\n@payloadcms/db-mongodb: 3.0.0\n@payloadcms/ui: 3.0.0')
})

it('returns empty string when payload missing', () => {
expect(formatForClipboard({})).toBe('')
})

it('handles a payload-only map', () => {
expect(formatForClipboard({ payload: '3.1.2' })).toBe('payload: 3.1.2')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const formatForClipboard = (deps: Record<string, string>): string => {
if (!deps.payload) {
return ''
}

const rest = Object.keys(deps)
.filter((k) => k !== 'payload')
.sort((a, b) => a.localeCompare(b))

const lines = [`payload: ${deps.payload}`, ...rest.map((k) => `${k}: ${deps[k]}`)]
return lines.join('\n')
}
Loading
Loading