{t('@jhb.software/payload-alt-text-plugin:statusUnhealthy', {
diff --git a/alt-text/src/components/BulkGenerateAltTextsButton.test.tsx b/alt-text/src/components/BulkGenerateAltTextsButton.test.tsx
new file mode 100644
index 00000000..c20c837b
--- /dev/null
+++ b/alt-text/src/components/BulkGenerateAltTextsButton.test.tsx
@@ -0,0 +1,87 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mockConfig = { routes: { api: '/api' }, serverURL: '' }
+const mockSetSelection = vi.fn()
+
+vi.mock('next/navigation.js', () => ({
+ useRouter: () => ({ refresh: vi.fn() }),
+}))
+
+vi.mock('@payloadcms/ui', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ }: {
+ children: React.ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ }) => (
+
+ ),
+ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
+ useConfig: () => ({ config: mockConfig }),
+ useSelection: () => ({
+ selected: new Map([
+ ['doc-1', true],
+ ['doc-2', true],
+ ]),
+ setSelection: mockSetSelection,
+ }),
+ useTranslation: () => ({ t: (key: string) => key }),
+}))
+
+const { BulkGenerateAltTextsButton } = await import('./BulkGenerateAltTextsButton.js')
+
+describe('BulkGenerateAltTextsButton', () => {
+ beforeEach(() => {
+ mockConfig.routes.api = '/api'
+ mockConfig.serverURL = ''
+ mockSetSelection.mockClear()
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() =>
+ Promise.resolve({
+ json: () => Promise.resolve({ erroredDocs: [], totalDocs: 2, updatedDocs: 2 }),
+ ok: true,
+ } as Response),
+ ),
+ )
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ })
+
+ it('posts to the default /api route when no custom route is configured', async () => {
+ render(
)
+ fireEvent.click(screen.getByRole('button'))
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/alt-text-plugin/generate/bulk',
+ expect.objectContaining({ method: 'POST' }),
+ )
+ })
+
+ it('posts to the configured API route when a custom route is set', async () => {
+ mockConfig.routes.api = '/custom-api'
+ mockConfig.serverURL = 'https://cms.example.com'
+
+ render(
)
+ fireEvent.click(screen.getByRole('button'))
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://cms.example.com/custom-api/alt-text-plugin/generate/bulk',
+ expect.objectContaining({ method: 'POST' }),
+ )
+ })
+})
diff --git a/alt-text/src/components/BulkGenerateAltTextsButton.tsx b/alt-text/src/components/BulkGenerateAltTextsButton.tsx
index 6aad2e79..0107827a 100644
--- a/alt-text/src/components/BulkGenerateAltTextsButton.tsx
+++ b/alt-text/src/components/BulkGenerateAltTextsButton.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Button, toast, useSelection, useTranslation } from '@payloadcms/ui'
+import { Button, toast, useConfig, useSelection, useTranslation } from '@payloadcms/ui'
import { useRouter } from 'next/navigation.js'
import { useTransition } from 'react'
@@ -16,6 +16,12 @@ export function BulkGenerateAltTextsButton({ collectionSlug }: { collectionSlug:
const { t } = useTranslation
()
const [isPending, startTransition] = useTransition()
const { selected, setSelection } = useSelection()
+ const {
+ config: {
+ routes: { api: apiRoute },
+ serverURL,
+ },
+ } = useConfig()
const selectedIds = Array.from(selected.entries())
.filter(([, isSelected]) => isSelected)
@@ -30,13 +36,16 @@ export function BulkGenerateAltTextsButton({ collectionSlug }: { collectionSlug:
}
try {
- const response = await fetch('/api/alt-text-plugin/generate/bulk', {
- body: JSON.stringify({
- collection: collectionSlug,
- ids: selectedIds,
- }),
- method: 'POST',
- })
+ const response = await fetch(
+ `${serverURL ?? ''}${apiRoute}/alt-text-plugin/generate/bulk`,
+ {
+ body: JSON.stringify({
+ collection: collectionSlug,
+ ids: selectedIds,
+ }),
+ method: 'POST',
+ },
+ )
if (!response.ok) {
toast.error(t('@jhb.software/payload-alt-text-plugin:failedToGenerate'))
diff --git a/alt-text/src/components/GenerateAltTextButton.test.tsx b/alt-text/src/components/GenerateAltTextButton.test.tsx
new file mode 100644
index 00000000..64f8750f
--- /dev/null
+++ b/alt-text/src/components/GenerateAltTextButton.test.tsx
@@ -0,0 +1,92 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mockConfig = { routes: { api: '/api' }, serverURL: '' }
+const mockSetAltText = vi.fn()
+const mockSetKeywords = vi.fn()
+
+vi.mock('@payloadcms/ui', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ }: {
+ children: React.ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ }) => (
+
+ ),
+ toast: { error: vi.fn(), success: vi.fn() },
+ useConfig: () => ({ config: mockConfig }),
+ useDocumentInfo: () => ({ id: 'doc-1', collectionSlug: 'media' }),
+ useField: ({ path }: { path: string }) => {
+ if (path === 'alt') {
+ return { setValue: mockSetAltText, value: '' }
+ }
+ if (path === 'keywords') {
+ return { setValue: mockSetKeywords, value: '' }
+ }
+ if (path === 'mimeType') {
+ return { setValue: vi.fn(), value: 'image/png' }
+ }
+ return { setValue: vi.fn(), value: '' }
+ },
+ useLocale: () => ({ code: 'en' }),
+ useTranslation: () => ({ t: (key: string) => key }),
+}))
+
+const { GenerateAltTextButton } = await import('./GenerateAltTextButton.js')
+
+describe('GenerateAltTextButton', () => {
+ beforeEach(() => {
+ mockConfig.routes.api = '/api'
+ mockConfig.serverURL = ''
+ mockSetAltText.mockClear()
+ mockSetKeywords.mockClear()
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() =>
+ Promise.resolve({
+ json: () => Promise.resolve({ altText: 'A cat', keywords: ['cat'] }),
+ ok: true,
+ } as Response),
+ ),
+ )
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ })
+
+ it('posts to the default /api route when no custom route is configured', async () => {
+ render()
+ fireEvent.click(screen.getByRole('button'))
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/alt-text-plugin/generate',
+ expect.objectContaining({ method: 'POST' }),
+ )
+ })
+
+ it('posts to the configured API route when a custom route is set', async () => {
+ mockConfig.routes.api = '/custom-api'
+ mockConfig.serverURL = 'https://cms.example.com'
+
+ render()
+ fireEvent.click(screen.getByRole('button'))
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://cms.example.com/custom-api/alt-text-plugin/generate',
+ expect.objectContaining({ method: 'POST' }),
+ )
+ })
+})
diff --git a/alt-text/src/components/GenerateAltTextButton.tsx b/alt-text/src/components/GenerateAltTextButton.tsx
index 6004ba6c..a987a1c8 100644
--- a/alt-text/src/components/GenerateAltTextButton.tsx
+++ b/alt-text/src/components/GenerateAltTextButton.tsx
@@ -1,6 +1,14 @@
'use client'
-import { Button, toast, useDocumentInfo, useField, useLocale, useTranslation } from '@payloadcms/ui'
+import {
+ Button,
+ toast,
+ useConfig,
+ useDocumentInfo,
+ useField,
+ useLocale,
+ useTranslation,
+} from '@payloadcms/ui'
import { useTransition } from 'react'
import type {
@@ -16,6 +24,12 @@ export function GenerateAltTextButton({ supportedMimeTypes }: { supportedMimeTyp
const { id, collectionSlug } = useDocumentInfo()
const locale = useLocale()
const [isPending, startTransition] = useTransition()
+ const {
+ config: {
+ routes: { api: apiRoute },
+ serverURL,
+ },
+ } = useConfig()
const { setValue: setKeywords } = useField({ path: 'keywords' })
const { setValue: setAltText } = useField({ path: 'alt' })
@@ -32,7 +46,7 @@ export function GenerateAltTextButton({ supportedMimeTypes }: { supportedMimeTyp
startTransition(async () => {
try {
- const response = await fetch('/api/alt-text-plugin/generate', {
+ const response = await fetch(`${serverURL ?? ''}${apiRoute}/alt-text-plugin/generate`, {
body: JSON.stringify({
id: id as string,
collection: collectionSlug,
diff --git a/alt-text/tsconfig.build.json b/alt-text/tsconfig.build.json
new file mode 100644
index 00000000..07310ec7
--- /dev/null
+++ b/alt-text/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["./src/**/*.test.ts", "./src/**/*.test.tsx"]
+}
diff --git a/alt-text/vitest.config.ts b/alt-text/vitest.config.ts
new file mode 100644
index 00000000..077c3851
--- /dev/null
+++ b/alt-text/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ oxc: {
+ jsx: 'automatic',
+ },
+ test: {
+ // The legacy test/*.test.ts files use node:test instead of vitest.
+ // They are executed separately via `test:health`.
+ exclude: ['**/node_modules/**', '**/dev/**', '**/dev_unlocalized/**', 'test/**'],
+ },
+})
diff --git a/pages/CHANGELOG.md b/pages/CHANGELOG.md
index 36fddd12..3166de33 100644
--- a/pages/CHANGELOG.md
+++ b/pages/CHANGELOG.md
@@ -2,6 +2,7 @@
## Unreleased
+- fix: respect a user-customized `routes.api` when `getBreadcrumbs` is called from client-side field components. `getBreadcrumbs` now takes an optional `apiURL` argument (required when called without a `req`) that the `PathField` supplies from `useConfig()`. The internal `fetchRestApi` helper has been removed and inlined.
- style: standardize icons to use Geist icon set (16x16 filled)
- refactor: use i18next interpolation for translations
diff --git a/pages/package.json b/pages/package.json
index ea3f0a00..d4a56e41 100644
--- a/pages/package.json
+++ b/pages/package.json
@@ -22,7 +22,8 @@
"clean": "rimraf --glob {dist,*.tsbuildinfo}",
"dev": "tsc -w",
"format": "prettier --write src \"*.{json,md,js,mjs,cjs}\"",
- "test": "pnpm test:localized && pnpm test:unlocalized && pnpm test:multi-tenant",
+ "test": "pnpm test:unit && pnpm test:localized && pnpm test:unlocalized && pnpm test:multi-tenant",
+ "test:unit": "vitest run",
"test:localized": "pnpm test:localized:mongodb && pnpm test:localized:sqlite",
"test:localized:mongodb": "cd dev && pnpm test:mongodb",
"test:localized:sqlite": "cd dev && pnpm test:sqlite",
@@ -57,7 +58,8 @@
"eslint": "^9.0.0",
"prettier": "^3.8.3",
"rimraf": "6.1.3",
- "typescript": "5.9.3"
+ "typescript": "5.9.3",
+ "vitest": "^4.1.2"
},
"files": [
"dist"
diff --git a/pages/pnpm-lock.yaml b/pages/pnpm-lock.yaml
index 53e50250..d0e2c5c4 100644
--- a/pages/pnpm-lock.yaml
+++ b/pages/pnpm-lock.yaml
@@ -57,6 +57,9 @@ importers:
typescript:
specifier: 5.9.3
version: 5.9.3
+ vitest:
+ specifier: ^4.1.2
+ version: 4.1.4(@types/node@25.6.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(sass@1.77.4)(tsx@4.21.0))
dev:
dependencies:
@@ -5913,7 +5916,7 @@ snapshots:
'@typescript-eslint/eslint-plugin@8.26.1(@typescript-eslint/parser@8.26.1(eslint@9.22.0)(typescript@5.9.3))(eslint@9.22.0)(typescript@5.7.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.26.1(eslint@9.22.0)(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.26.1(eslint@9.22.0)(typescript@5.7.3)
'@typescript-eslint/scope-manager': 8.26.1
'@typescript-eslint/type-utils': 8.26.1(eslint@9.22.0)(typescript@5.7.3)
'@typescript-eslint/utils': 8.26.1(eslint@9.22.0)(typescript@5.7.3)
@@ -5930,7 +5933,7 @@ snapshots:
'@typescript-eslint/eslint-plugin@8.26.1(@typescript-eslint/parser@8.26.1(eslint@9.22.0)(typescript@5.9.3))(eslint@9.22.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.26.1(eslint@9.22.0)(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.26.1(eslint@9.22.0)(typescript@5.7.3)
'@typescript-eslint/scope-manager': 8.26.1
'@typescript-eslint/type-utils': 8.26.1(eslint@9.22.0)(typescript@5.9.3)
'@typescript-eslint/utils': 8.26.1(eslint@9.22.0)(typescript@5.9.3)
@@ -5957,18 +5960,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.26.1(eslint@9.22.0)(typescript@5.9.3)':
- dependencies:
- '@typescript-eslint/scope-manager': 8.26.1
- '@typescript-eslint/types': 8.26.1
- '@typescript-eslint/typescript-estree': 8.26.1(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.26.1
- debug: 4.4.3
- eslint: 9.22.0
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
'@typescript-eslint/project-service@8.58.2(typescript@5.7.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.7.3)
@@ -6058,6 +6049,7 @@ snapshots:
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
+ optional: true
'@typescript-eslint/typescript-estree@8.58.2(typescript@5.7.3)':
dependencies:
@@ -8798,6 +8790,7 @@ snapshots:
ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
+ optional: true
ts-essentials@10.0.3(typescript@5.9.3):
optionalDependencies:
diff --git a/pages/src/components/client/PathField.tsx b/pages/src/components/client/PathField.tsx
index cca5472b..8bc006ad 100644
--- a/pages/src/components/client/PathField.tsx
+++ b/pages/src/components/client/PathField.tsx
@@ -68,6 +68,7 @@ export const PathField: TextFieldClientComponent = ({ field, path: fieldPath })
doc[breadcrumbLabelFieldName] = breadcrumbLabel
const fechtchedBreadcrumbs = (await getBreadcrumbsForDoc({
+ apiURL: `${config.serverURL ?? ''}${config.routes.api}`,
breadcrumbLabelField: breadcrumbLabelFieldName,
data: doc,
locale,
diff --git a/pages/src/utils/fetchRestApi.ts b/pages/src/utils/fetchRestApi.ts
deleted file mode 100644
index 58bc7381..00000000
--- a/pages/src/utils/fetchRestApi.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { stringify } from 'qs-esm'
-
-/** Fetches a document via the Payload REST API. This should only be used if the local API is not available. */
-export async function fetchRestApi(path: string, options: Record): Promise {
- const response = await fetch('/api' + path + '?' + stringify(options), {
- headers: {
- 'Content-Type': 'application/json',
- },
- method: 'GET',
- })
-
- if (!response.ok) {
- throw new Error(
- `Failed to fetch the requested document via the Payload REST API. ${response.statusText}`,
- )
- }
-
- return await response.json()
-}
diff --git a/pages/src/utils/getBreadcrumbs.ts b/pages/src/utils/getBreadcrumbs.ts
index 4b5def2a..0af0013c 100644
--- a/pages/src/utils/getBreadcrumbs.ts
+++ b/pages/src/utils/getBreadcrumbs.ts
@@ -1,14 +1,16 @@
import type { CollectionSlug, PayloadRequest } from 'payload'
+import { stringify } from 'qs-esm'
+
import type { Breadcrumb } from '../types/Breadcrumb.js'
import type { Locale } from '../types/Locale.js'
-import { fetchRestApi } from './fetchRestApi.js'
import { pathFromBreadcrumbs } from './pathFromBreadcrumbs.js'
import { ROOT_PAGE_SLUG } from './setRootPageVirtualFields.js'
/** Returns the breadcrumbs to the given document. */
export async function getBreadcrumbs({
+ apiURL,
breadcrumbLabelField,
data,
locale,
@@ -17,6 +19,12 @@ export async function getBreadcrumbs({
parentField,
req,
}: {
+ /**
+ * Base URL of the Payload REST API (e.g. `${serverURL}${routes.api}`).
+ * Required when `req` is undefined (i.e. when called from a client component)
+ * so the plugin respects a user-customized `routes.api`.
+ */
+ apiURL?: string
breadcrumbLabelField: string
data: Record
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
@@ -61,23 +69,30 @@ export async function getBreadcrumbs({
throw new Error('Parent ID not found for document with id ' + data.id)
}
- const parent = req
- ? await findByIDCached({
- id: parentId,
- collection: parentCollection,
- locale,
- req,
- })
- : await fetchRestApi<{ breadcrumbs: Breadcrumb[]; id: number | string }>(
- `/${parentCollection}/${parentId}`,
- {
- depth: 0,
- locale,
- select: {
- breadcrumbs: true,
- },
- },
+ let parent: null | Record | undefined
+ if (req) {
+ parent = await findByIDCached({
+ id: parentId,
+ collection: parentCollection,
+ locale,
+ req,
+ })
+ } else {
+ if (!apiURL) {
+ throw new Error('[Pages Plugin] getBreadcrumbs requires `apiURL` when called without `req`.')
+ }
+ const query = stringify({ depth: 0, locale, select: { breadcrumbs: true } })
+ const response = await fetch(`${apiURL}/${parentCollection}/${parentId}?${query}`, {
+ headers: { 'Content-Type': 'application/json' },
+ method: 'GET',
+ })
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch the parent document via the Payload REST API. ${response.statusText}`,
)
+ }
+ parent = (await response.json()) as Record
+ }
if (!parent) {
// This can be the case, when the parent document got deleted.
diff --git a/pages/test/getBreadcrumbs.test.ts b/pages/test/getBreadcrumbs.test.ts
new file mode 100644
index 00000000..a9d06ba6
--- /dev/null
+++ b/pages/test/getBreadcrumbs.test.ts
@@ -0,0 +1,76 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { getBreadcrumbs } from '../src/utils/getBreadcrumbs.js'
+
+describe('getBreadcrumbs (client path)', () => {
+ let recordedUrl: string | undefined
+
+ beforeEach(() => {
+ recordedUrl = undefined
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((input: RequestInfo | URL) => {
+ recordedUrl = typeof input === 'string' ? input : input.toString()
+ return Promise.resolve({
+ json: () =>
+ Promise.resolve({
+ breadcrumbs: [{ label: 'Root', path: '/root', slug: 'root' }],
+ id: 'parent-1',
+ }),
+ ok: true,
+ statusText: 'OK',
+ } as Response)
+ }),
+ )
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ test('fetches the parent via the provided apiURL', async () => {
+ await getBreadcrumbs({
+ apiURL: '/api',
+ breadcrumbLabelField: 'title',
+ data: { id: 'child-1', parent: 'parent-1', slug: 'child', title: 'Child' },
+ locale: undefined,
+ locales: undefined,
+ parentCollection: 'pages',
+ parentField: 'parent',
+ req: undefined,
+ })
+
+ expect(recordedUrl).toBeDefined()
+ expect(recordedUrl).toMatch(/^\/api\/pages\/parent-1/)
+ })
+
+ test('respects a user-customized routes.api baked into apiURL', async () => {
+ await getBreadcrumbs({
+ apiURL: 'https://cms.example.com/custom-api',
+ breadcrumbLabelField: 'title',
+ data: { id: 'child-1', parent: 'parent-1', slug: 'child', title: 'Child' },
+ locale: undefined,
+ locales: undefined,
+ parentCollection: 'pages',
+ parentField: 'parent',
+ req: undefined,
+ })
+
+ expect(recordedUrl).toBeDefined()
+ expect(recordedUrl).toMatch(/^https:\/\/cms\.example\.com\/custom-api\/pages\/parent-1/)
+ })
+
+ test('throws a clear error when called without req and without apiURL', async () => {
+ await expect(() =>
+ getBreadcrumbs({
+ breadcrumbLabelField: 'title',
+ data: { id: 'child-1', parent: 'parent-1', slug: 'child', title: 'Child' },
+ locale: undefined,
+ locales: undefined,
+ parentCollection: 'pages',
+ parentField: 'parent',
+ req: undefined,
+ }),
+ ).rejects.toThrow(/requires `apiURL` when called without `req`/)
+ })
+})
diff --git a/pages/vitest.config.ts b/pages/vitest.config.ts
new file mode 100644
index 00000000..3e427974
--- /dev/null
+++ b/pages/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ include: ['test/**/*.test.ts'],
+ },
+})
diff --git a/vercel-deployments/CHANGELOG.md b/vercel-deployments/CHANGELOG.md
index 9fb23322..ca996aa4 100644
--- a/vercel-deployments/CHANGELOG.md
+++ b/vercel-deployments/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## Unreleased
+
+- fix: respect a user-customized `routes.api` in the deployment poller and trigger button (the fetch previously hardcoded `/api/vercel-deployments`)
+
## 0.2.1
- style: standardize icons to use Geist icon set (16x16 filled)
diff --git a/vercel-deployments/eslint.config.js b/vercel-deployments/eslint.config.js
index 71a819a6..3f42ca69 100644
--- a/vercel-deployments/eslint.config.js
+++ b/vercel-deployments/eslint.config.js
@@ -30,6 +30,15 @@ export default [
'no-console': 'off', // TODO: remove this rule and use the Payload logger instead
},
},
+ {
+ // Test files use `vi.mock` to stub hooks like `useConfig`, `useRouter`, etc.
+ // The mock arrow functions share names with real hooks, so the rule flags them
+ // as "useless custom hooks" — but they are intentionally plain mocks.
+ files: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
+ rules: {
+ '@eslint-react/hooks-extra/no-useless-custom-hooks': 'off',
+ },
+ },
{
ignores: defaultESLintIgnores,
},
diff --git a/vercel-deployments/package.json b/vercel-deployments/package.json
index 4b5eb6e9..af436ff7 100644
--- a/vercel-deployments/package.json
+++ b/vercel-deployments/package.json
@@ -20,7 +20,7 @@
"types": "./src/index.ts",
"scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
- "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths --ignore '**/*.test.ts'",
+ "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths --ignore '**/*.test.ts,**/*.test.tsx'",
"build:types": "tsc --project tsconfig.build.json --outDir dist --rootDir ./src",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"clean": "rimraf --glob {dist,*.tsbuildinfo}",
diff --git a/vercel-deployments/src/components/DeploymentStatusPoller.test.tsx b/vercel-deployments/src/components/DeploymentStatusPoller.test.tsx
index 0a726a92..57057bbe 100644
--- a/vercel-deployments/src/components/DeploymentStatusPoller.test.tsx
+++ b/vercel-deployments/src/components/DeploymentStatusPoller.test.tsx
@@ -7,6 +7,11 @@ vi.mock('next/navigation.js', () => ({
useRouter: () => ({ refresh: mockRefresh }),
}))
+const mockConfig = { routes: { api: '/api' }, serverURL: '' }
+vi.mock('@payloadcms/ui', () => ({
+ useConfig: () => ({ config: mockConfig }),
+}))
+
const { DeploymentStatusPoller } = await import('./DeploymentStatusPoller.js')
function mockFetchResponse(data: object) {
@@ -42,6 +47,8 @@ describe('DeploymentStatusPoller', () => {
beforeEach(() => {
vi.useFakeTimers()
mockRefresh.mockClear()
+ mockConfig.routes.api = '/api'
+ mockConfig.serverURL = ''
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(mockFetchResponse(idleDeploymentsResponse))),
@@ -130,4 +137,47 @@ describe('DeploymentStatusPoller', () => {
expect(fetch).toHaveBeenCalledWith('/api/vercel-deployments', expect.anything())
})
+
+ it('uses the configured API route when polling the list endpoint', async () => {
+ mockConfig.routes.api = '/custom-api'
+ mockConfig.serverURL = 'https://cms.example.com'
+
+ render(
+
+
+ ,
+ )
+ await flushPolling()
+
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://cms.example.com/custom-api/vercel-deployments',
+ expect.anything(),
+ )
+ })
+
+ it('uses the configured API route when polling an active deployment', async () => {
+ mockConfig.routes.api = '/custom-api'
+ mockConfig.serverURL = 'https://cms.example.com'
+
+ // Start with a building deployment so the poller switches to active mode
+ vi.mocked(fetch).mockResolvedValue(mockFetchResponse(buildingDeploymentsResponse))
+
+ render(
+
+
+ ,
+ )
+ await flushPolling()
+
+ vi.mocked(fetch).mockResolvedValue(mockFetchResponse({ id: 'dpl-2', status: 'BUILDING' }))
+ vi.mocked(fetch).mockClear()
+
+ await vi.advanceTimersByTimeAsync(5 * 1000)
+ await flushPolling()
+
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining('https://cms.example.com/custom-api/vercel-deployments?id=dpl-2'),
+ expect.anything(),
+ )
+ })
})
diff --git a/vercel-deployments/src/components/DeploymentStatusPoller.tsx b/vercel-deployments/src/components/DeploymentStatusPoller.tsx
index b2744acd..6194b975 100644
--- a/vercel-deployments/src/components/DeploymentStatusPoller.tsx
+++ b/vercel-deployments/src/components/DeploymentStatusPoller.tsx
@@ -1,5 +1,6 @@
'use client'
+import { useConfig } from '@payloadcms/ui'
import { useRouter } from 'next/navigation.js'
import React, { createContext, use, useCallback, useEffect, useRef, useState } from 'react'
@@ -27,6 +28,13 @@ export const useDeploymentPoller = () => use(PollerContext)
*/
export const DeploymentStatusPoller: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const router = useRouter()
+ const {
+ config: {
+ routes: { api: apiRoute },
+ serverURL,
+ },
+ } = useConfig()
+ const deploymentsEndpoint = `${serverURL ?? ''}${apiRoute}/vercel-deployments`
const intervalRef = useRef>(null)
const activeDeploymentIdRef = useRef(null)
const [isBuilding, setIsBuilding] = useState(false)
@@ -59,7 +67,7 @@ export const DeploymentStatusPoller: React.FC<{ children: React.ReactNode }> = (
}
try {
- const res = await fetch(`/api/vercel-deployments?id=${encodeURIComponent(deploymentId)}`, {
+ const res = await fetch(`${deploymentsEndpoint}?id=${encodeURIComponent(deploymentId)}`, {
credentials: 'include',
})
if (!res.ok) {
@@ -87,12 +95,12 @@ export const DeploymentStatusPoller: React.FC<{ children: React.ReactNode }> = (
} catch {
// Silently ignore polling errors
}
- }, [router])
+ }, [router, deploymentsEndpoint])
// Poll the list endpoint to detect any in-progress deployments (idle mode)
const pollDeploymentsList = useCallback(async () => {
try {
- const res = await fetch('/api/vercel-deployments', { credentials: 'include' })
+ const res = await fetch(deploymentsEndpoint, { credentials: 'include' })
if (!res.ok) {
return
}
@@ -122,7 +130,7 @@ export const DeploymentStatusPoller: React.FC<{ children: React.ReactNode }> = (
} catch {
// Silently ignore polling errors
}
- }, [router])
+ }, [router, deploymentsEndpoint])
// Switch between idle and active polling based on build state
useEffect(() => {
diff --git a/vercel-deployments/src/components/TriggerDeploymentButton.test.tsx b/vercel-deployments/src/components/TriggerDeploymentButton.test.tsx
new file mode 100644
index 00000000..f5a67a9a
--- /dev/null
+++ b/vercel-deployments/src/components/TriggerDeploymentButton.test.tsx
@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mockNotifyBuildTriggered = vi.fn()
+vi.mock('./DeploymentStatusPoller.js', () => ({
+ useDeploymentPoller: () => ({ notifyBuildTriggered: mockNotifyBuildTriggered }),
+}))
+
+vi.mock('../react-hooks/useDashboardTranslation.js', () => ({
+ useDashboardTranslation: () => ({ t: (key: string) => key }),
+}))
+
+vi.mock('@payloadcms/ui', () => ({
+ Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
+
+ ),
+ toast: { error: vi.fn(), success: vi.fn() },
+ useConfig: () => ({ config: mockConfig }),
+}))
+
+const mockConfig = { routes: { api: '/api' }, serverURL: '' }
+
+const { TriggerFrontendDeploymentButton } = await import('./TriggerDeploymentButton.js')
+
+describe('TriggerFrontendDeploymentButton', () => {
+ beforeEach(() => {
+ mockConfig.routes.api = '/api'
+ mockConfig.serverURL = ''
+ mockNotifyBuildTriggered.mockClear()
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() =>
+ Promise.resolve({
+ json: () => Promise.resolve({ id: 'dpl-1' }),
+ ok: true,
+ } as Response),
+ ),
+ )
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ })
+
+ it('posts to the default /api route when no custom route is configured', async () => {
+ render()
+ fireEvent.click(screen.getByRole('button'))
+ // wait for the transition microtask + fetch promise to settle
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/vercel-deployments',
+ expect.objectContaining({ method: 'POST' }),
+ )
+ })
+
+ it('posts to the configured API route when a custom route is set', async () => {
+ mockConfig.routes.api = '/custom-api'
+ mockConfig.serverURL = 'https://cms.example.com'
+
+ render()
+ fireEvent.click(screen.getByRole('button'))
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://cms.example.com/custom-api/vercel-deployments',
+ expect.objectContaining({ method: 'POST' }),
+ )
+ })
+})
diff --git a/vercel-deployments/src/components/TriggerDeploymentButton.tsx b/vercel-deployments/src/components/TriggerDeploymentButton.tsx
index b31c605e..b3960121 100644
--- a/vercel-deployments/src/components/TriggerDeploymentButton.tsx
+++ b/vercel-deployments/src/components/TriggerDeploymentButton.tsx
@@ -1,5 +1,5 @@
'use client'
-import { Button, toast } from '@payloadcms/ui'
+import { Button, toast, useConfig } from '@payloadcms/ui'
import React, { useTransition } from 'react'
import { useDashboardTranslation } from '../react-hooks/useDashboardTranslation.js'
@@ -11,11 +11,17 @@ export const TriggerFrontendDeploymentButton: React.FC = () => {
const [isPending, startTransition] = useTransition()
const { t } = useDashboardTranslation()
const { notifyBuildTriggered } = useDeploymentPoller()
+ const {
+ config: {
+ routes: { api: apiRoute },
+ serverURL,
+ },
+ } = useConfig()
const handleClick = () => {
startTransition(async () => {
try {
- const res = await fetch('/api/vercel-deployments', {
+ const res = await fetch(`${serverURL ?? ''}${apiRoute}/vercel-deployments`, {
credentials: 'include',
method: 'POST',
})