diff --git a/CLAUDE.md b/CLAUDE.md index 13b754ed746..bdd446e9a28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,6 +233,7 @@ Generate types for a test directory: `pnpm run dev:generate-types `. 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`). diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index e65799c6e6d..a0c064692f1 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -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 => { const sanitizedConfig = { ...configToSanitize } @@ -118,6 +119,15 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise = 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 = [] } diff --git a/packages/payload/src/config/sanitizeDependencies.spec.ts b/packages/payload/src/config/sanitizeDependencies.spec.ts new file mode 100644 index 00000000000..62dfd357f04 --- /dev/null +++ b/packages/payload/src/config/sanitizeDependencies.spec.ts @@ -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') + }) +}) diff --git a/packages/payload/src/config/sanitizeDependencies.ts b/packages/payload/src/config/sanitizeDependencies.ts new file mode 100644 index 00000000000..0e2c5788aa6 --- /dev/null +++ b/packages/payload/src/config/sanitizeDependencies.ts @@ -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 => { + 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> => { + try { + const result = await getDependencies(process.cwd(), ['payload', ...PAYLOAD_PACKAGE_LIST]) + const map: Record = {} + 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() } + } +} diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 8bbfcbf8eb3..3e9484a8cab 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -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 } /** @@ -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 timezones: SanitizedTimezoneConfig } & DeepRequired blocks?: FlattenedBlock[] diff --git a/packages/payload/src/config/versionMenu.spec.ts b/packages/payload/src/config/versionMenu.spec.ts new file mode 100644 index 00000000000..fbf3b9fc493 --- /dev/null +++ b/packages/payload/src/config/versionMenu.spec.ts @@ -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) + }) +}) diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx new file mode 100644 index 00000000000..70728a6ab93 --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx @@ -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 +}> = ({ versions }) => { + const { closeModal, isModalOpen, openModal } = useModal() + const payloadVersion = versions.payload ?? '0.0.0' + + return ( + + openModal(modalSlug)}> + {`Payload v${payloadVersion}`} + + {isModalOpen(modalSlug) && ( + +
+
+

Payload Version Info

+ + +
+ +
+
+ )} +
+ ) +} diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/VersionList.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/VersionList.tsx new file mode 100644 index 00000000000..5a2612af18c --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/VersionList.tsx @@ -0,0 +1,25 @@ +'use client' + +import React from 'react' + +export const VersionList: React.FC<{ + versions: Record +}> = ({ 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 ( +
+ {ordered.map(([name, version]) => ( +
+
{name}
+
{version}
+
+ ))} +
+ ) +} diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.spec.ts b/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.spec.ts new file mode 100644 index 00000000000..1a6bdcab3b1 --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.spec.ts @@ -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') + }) +}) diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.ts b/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.ts new file mode 100644 index 00000000000..c8bd1afb534 --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.ts @@ -0,0 +1,12 @@ +export const formatForClipboard = (deps: Record): 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') +} diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/index.css b/packages/ui/src/elements/PayloadVersionMenuItem/index.css new file mode 100644 index 00000000000..9e59b858b23 --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/index.css @@ -0,0 +1,77 @@ +@layer payload-default { + .payload-version-menu-item { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + } + + .payload-version-menu-item__wrapper { + z-index: 1; + position: relative; + display: flex; + flex-direction: column; + gap: var(--spacer-2-5); + padding: var(--spacer-5); + max-width: 36rem; + min-width: 20rem; + background: var(--bg-default-default); + border: 1px solid var(--border-default-default); + border-radius: var(--radius-medium); + } + + .payload-version-menu-item__header { + display: flex; + align-items: center; + gap: var(--spacer-2); + } + + .payload-version-menu-item__header h2 { + margin: 0; + margin-right: auto; + font-size: 1.125rem; + } + + .payload-version-menu-item__close { + background: transparent; + border: 0; + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + padding: 0; + color: var(--text-default-secondary); + } + + .payload-version-menu-item__close:hover { + color: var(--text-default-default); + } + + .payload-version-menu-item__list { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--spacer-1); + } + + .payload-version-menu-item__row { + display: flex; + justify-content: space-between; + gap: var(--spacer-3); + font-family: var(--font-family-mono); + font-size: 0.8125rem; + font-weight: normal; + } + + .payload-version-menu-item__row dt { + color: var(--text-default-default); + } + + .payload-version-menu-item__row dd { + margin: 0; + color: var(--text-default-secondary); + } + + .payload-version-menu-item__button { + color: var(--text-default-secondary); + } +} diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/index.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/index.tsx new file mode 100644 index 00000000000..4ca7565a254 --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/index.tsx @@ -0,0 +1,11 @@ +import type { ServerProps } from 'payload' + +import React from 'react' + +// eslint-disable-next-line payload/no-imports-from-exports-dir -- Server component must reference exports dir for proper client boundary +import { PayloadVersionModalTrigger } from '../../exports/client/index.js' + +export const PayloadVersionMenuItem: React.FC = ({ payload }) => { + const versions = payload?.config?.admin?.packageVersions ?? { payload: '0.0.0' } + return +} diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 82ae9c29569..d27ccb158e9 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -489,4 +489,5 @@ export type { } from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js' export { useLivePreviewContext } from '../../providers/LivePreview/context.js' +export { PayloadVersionModalTrigger } from '../../elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.js' export { LivePreviewWindow } from '../../elements/LivePreview/Window/index.js' diff --git a/packages/ui/src/exports/rsc/index.ts b/packages/ui/src/exports/rsc/index.ts index 01d92bd74ac..fd18de82b8e 100644 --- a/packages/ui/src/exports/rsc/index.ts +++ b/packages/ui/src/exports/rsc/index.ts @@ -8,6 +8,7 @@ export { getHTMLDiffComponents, unescapeDiffHTML, } from '../../elements/HTMLDiff/index.js' +export { PayloadVersionMenuItem } from '../../elements/PayloadVersionMenuItem/index.js' export { _internal_renderFieldHandler } from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js' export { File } from '../../graphics/File/index.js' export { CheckIcon } from '../../icons/Check/index.js' diff --git a/test/admin/e2e/general/e2e.spec.ts b/test/admin/e2e/general/e2e.spec.ts index 159772eaf46..c4c83c111c5 100644 --- a/test/admin/e2e/general/e2e.spec.ts +++ b/test/admin/e2e/general/e2e.spec.ts @@ -1154,6 +1154,37 @@ describe('General', () => { }) }) }) + + describe('Payload version menu item', () => { + test('should show Payload version button, open drawer, and copy version to clipboard', async ({}, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + await page.goto(postsUrl.admin) + await openNav(page) + + const gearButton = page.locator('.nav__controls .popup#settings-menu .popup-button') + await gearButton.click() + + const versionButton = page.locator( + '[data-popup-id="settings-menu"] .popup-button-list__button', + { + hasText: /^Payload v\d+\.\d+/, + }, + ) + await expect(versionButton).toBeVisible() + await versionButton.click() + + const drawer = page.locator('#payload-version-info') + await expect(drawer).toBeVisible() + + await expect(drawer.locator('dt', { hasText: 'payload' }).first()).toBeVisible() + + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await drawer.getByRole('button', { name: /^Copy/ }).click() + + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardText).toMatch(/^payload: \d+\.\d+\.\d+/) + }) + }) }) async function createPost(overrides?: Partial): Promise {