diff --git a/.changeset/move-fields-directory.md b/.changeset/move-fields-directory.md new file mode 100644 index 00000000..4f188548 --- /dev/null +++ b/.changeset/move-fields-directory.md @@ -0,0 +1,5 @@ +--- +'@ainsleydev/payload-helper': patch +--- + +Add PublishedAt and URLField field helpers and expose via ./fields export path diff --git a/.changeset/payload-helper-minor.md b/.changeset/payload-helper-minor.md new file mode 100644 index 00000000..465441ec --- /dev/null +++ b/.changeset/payload-helper-minor.md @@ -0,0 +1,5 @@ +--- +"@ainsleydev/sveltekit-helper": minor +--- + +Add Payload SEO helper components and tidy metadata handling. diff --git a/packages/payload-helper/package.json b/packages/payload-helper/package.json index 0e8873d5..c720208b 100644 --- a/packages/payload-helper/package.json +++ b/packages/payload-helper/package.json @@ -43,6 +43,10 @@ "types": "./dist/endpoints/index.d.ts", "import": "./dist/endpoints/index.js" }, + "./fields": { + "types": "./dist/fields/index.d.ts", + "import": "./dist/fields/index.js" + }, "./dist/admin/components/*": { "types": "./dist/admin/components/*.d.ts", "import": "./dist/admin/components/*.js", diff --git a/packages/payload-helper/src/common/index.ts b/packages/payload-helper/src/common/index.ts index f13e597f..95994eaa 100644 --- a/packages/payload-helper/src/common/index.ts +++ b/packages/payload-helper/src/common/index.ts @@ -1 +1,5 @@ export { SEOFields } from './SEO.js'; +export { PublishedAt } from '../fields/PublishedAt.js'; +export type { PublishedAtArgs } from '../fields/PublishedAt.js'; +export { URLField } from '../fields/URLField.js'; +export type { URLFieldArgs } from '../fields/URLField.js'; diff --git a/packages/payload-helper/src/fields/PublishedAt.test.ts b/packages/payload-helper/src/fields/PublishedAt.test.ts new file mode 100644 index 00000000..b3bc59dd --- /dev/null +++ b/packages/payload-helper/src/fields/PublishedAt.test.ts @@ -0,0 +1,130 @@ +import type { DateField } from 'payload'; +import { describe, expect, test, vi } from 'vitest'; +import { PublishedAt } from './PublishedAt.js'; + +describe('PublishedAt', () => { + test('returns a date field with correct defaults', () => { + const field = PublishedAt(); + + expect(field).toMatchObject({ + name: 'publishedAt', + type: 'date', + required: true, + admin: { + position: 'sidebar', + date: { + pickerAppearance: 'dayOnly', + }, + }, + }); + }); + + test('sets default value to current ISO date string', () => { + const field = PublishedAt(); + const defaultValue = (field as DateField).defaultValue; + + expect(typeof defaultValue).toBe('function'); + + const before = new Date().toISOString(); + const result = (defaultValue as () => string)(); + const after = new Date().toISOString(); + + expect(result >= before).toBe(true); + expect(result <= after).toBe(true); + }); + + test('sets date when status changes to published and value is empty', () => { + const field = PublishedAt(); + const hooks = (field as DateField).hooks?.beforeChange; + expect(hooks).toHaveLength(1); + + const before = new Date(); + const result = hooks?.[0]({ + siblingData: { _status: 'published' }, + value: undefined, + } as unknown as Parameters< + NonNullable['beforeChange']>[0] + >[0]); + const after = new Date(); + + expect(result).toBeInstanceOf(Date); + expect((result as Date).getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect((result as Date).getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + test('preserves existing value when status is published', () => { + const field = PublishedAt(); + const hooks = (field as DateField).hooks?.beforeChange; + + const existingDate = '2025-01-15T00:00:00.000Z'; + const result = hooks?.[0]({ + siblingData: { _status: 'published' }, + value: existingDate, + } as unknown as Parameters< + NonNullable['beforeChange']>[0] + >[0]); + + expect(result).toBe(existingDate); + }); + + test('returns value as-is when status is not published', () => { + const field = PublishedAt(); + const hooks = (field as DateField).hooks?.beforeChange; + + const result = hooks?.[0]({ + siblingData: { _status: 'draft' }, + value: undefined, + } as unknown as Parameters< + NonNullable['beforeChange']>[0] + >[0]); + + expect(result).toBeUndefined(); + }); + + test('applies overrides to the field', () => { + const field = PublishedAt({ + overrides: { + required: false, + label: 'Date Published', + }, + }); + + expect(field).toMatchObject({ + name: 'publishedAt', + type: 'date', + required: false, + label: 'Date Published', + }); + }); + + test('deep merges admin overrides with defaults', () => { + const field = PublishedAt({ + overrides: { + admin: { + position: 'sidebar', + description: 'Custom description', + }, + }, + }); + + expect((field as DateField).admin).toMatchObject({ + position: 'sidebar', + description: 'Custom description', + date: { + pickerAppearance: 'dayOnly', + }, + }); + }); + + test('returns correct defaults with no arguments', () => { + const field = PublishedAt(); + + expect(field).toMatchObject({ + name: 'publishedAt', + type: 'date', + required: true, + }); + expect((field as DateField).hooks?.beforeChange).toHaveLength(1); + expect(typeof (field as DateField).defaultValue).toBe('function'); + }); +}); diff --git a/packages/payload-helper/src/fields/PublishedAt.ts b/packages/payload-helper/src/fields/PublishedAt.ts new file mode 100644 index 00000000..448694a9 --- /dev/null +++ b/packages/payload-helper/src/fields/PublishedAt.ts @@ -0,0 +1,40 @@ +import type { DateField, Field } from 'payload'; +import { deepMerge } from 'payload'; + +export type PublishedAtArgs = { + overrides?: Partial; +}; + +/** + * Creates a published at date field with sensible defaults. + * + * Automatically sets the current date as default value and populates + * the field when a document is first published. + * + * @param args - Optional arguments to customise the field. + */ +export const PublishedAt = (args?: PublishedAtArgs): Field => { + const baseField: Field = { + name: 'publishedAt', + type: 'date', + required: true, + defaultValue: () => new Date().toISOString(), + admin: { + position: 'sidebar', + date: { + pickerAppearance: 'dayOnly', + }, + }, + hooks: { + beforeChange: [ + ({ siblingData, value }) => { + if (siblingData._status === 'published' && !value) { + return new Date(); + } + return value; + }, + ], + }, + }; + return deepMerge>(baseField, args?.overrides || {}); +}; diff --git a/packages/payload-helper/src/fields/URLField.test.ts b/packages/payload-helper/src/fields/URLField.test.ts new file mode 100644 index 00000000..0d0f6fde --- /dev/null +++ b/packages/payload-helper/src/fields/URLField.test.ts @@ -0,0 +1,98 @@ +import type { Field, FieldHookArgs, TextField, TypeWithID } from 'payload'; +import { describe, expect, test, vi } from 'vitest'; +import { URLField } from './URLField.js'; + +describe('URLField', () => { + test('returns a text field with correct defaults', () => { + const field = URLField({ + generate: () => 'https://example.com', + }); + + expect(field).toMatchObject({ + name: 'url', + label: 'URL', + type: 'text', + virtual: true, + admin: { + readOnly: true, + position: 'sidebar', + }, + }); + }); + + test('calls generate function in afterRead hook', async () => { + const generate = vi.fn(() => 'https://example.com/page'); + + const field = URLField({ generate }); + const hooks = (field as TextField).hooks?.afterRead; + expect(hooks).toHaveLength(1); + + const result = await hooks?.[0]({ draft: false } as unknown as FieldHookArgs); + expect(generate).toHaveBeenCalled(); + expect(result).toBe('https://example.com/page'); + }); + + test('appends draft query parameter when in draft mode', async () => { + const field = URLField({ + generate: () => 'https://example.com/page', + }); + + const hooks = (field as TextField).hooks?.afterRead; + const result = await hooks?.[0]({ draft: true } as unknown as FieldHookArgs); + expect(result).toBe('https://example.com/page?draft=true'); + }); + + test('appends draft parameter correctly when URL already has query params', async () => { + const field = URLField({ + generate: () => 'https://example.com/page?foo=bar', + }); + + const hooks = (field as TextField).hooks?.afterRead; + const result = await hooks?.[0]({ draft: true } as unknown as FieldHookArgs); + expect(result).toBe('https://example.com/page?foo=bar&draft=true'); + }); + + test('handles async generate function', async () => { + const field = URLField({ + generate: async () => 'https://example.com/async', + }); + + const hooks = (field as TextField).hooks?.afterRead; + const result = await hooks?.[0]({ draft: false } as unknown as FieldHookArgs); + expect(result).toBe('https://example.com/async'); + }); + + test('applies overrides to the base field', () => { + const field = URLField({ + generate: () => 'https://example.com', + overrides: { + name: 'customUrl', + label: 'Custom URL', + }, + }); + + expect(field).toMatchObject({ + name: 'customUrl', + label: 'Custom URL', + type: 'text', + }); + }); + + test('returns undefined when generate returns undefined', async () => { + const field = URLField({ generate: async () => undefined }); + const hooks = (field as TextField).hooks?.afterRead; + const result = await hooks?.[0]({ draft: true } as unknown as FieldHookArgs); + expect(result).toBeUndefined(); + }); + + test('uses empty object when overrides not provided', () => { + const field = URLField({ + generate: () => 'https://example.com', + }); + + expect(field).toMatchObject({ + name: 'url', + type: 'text', + }); + }); +}); diff --git a/packages/payload-helper/src/fields/URLField.ts b/packages/payload-helper/src/fields/URLField.ts new file mode 100644 index 00000000..357220d7 --- /dev/null +++ b/packages/payload-helper/src/fields/URLField.ts @@ -0,0 +1,49 @@ +import type { Field, FieldHookArgs, TextField, TypeWithID } from 'payload'; +import { deepMerge } from 'payload'; + +export type URLFieldArgs = { + overrides?: Partial>; + generate: (args: FieldHookArgs) => string | Promise; +}; + +/** + * Creates a virtual URL field with a custom generation function. + * + * @param generate - A function that generates the URL based on the field data. + * @param overrides - Optional overrides to customise the field. + */ +export const URLField = ({ generate, overrides }: URLFieldArgs): Field => { + const baseField: Field = { + name: 'url', + label: 'URL', + type: 'text', + admin: { + readOnly: true, + position: 'sidebar', + }, + virtual: true, + hooks: { + afterRead: [ + async (args: FieldHookArgs) => { + const url = await generate(args); + if (!url) { + return url; + } + if (args.draft) { + try { + const u = new URL(url); + u.searchParams.set('draft', 'true'); + return u.toString(); + } catch { + // Relative URL — append manually + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}draft=true`; + } + } + return url; + }, + ], + }, + }; + return deepMerge>>(baseField, overrides || {}); +}; diff --git a/packages/payload-helper/src/fields/index.ts b/packages/payload-helper/src/fields/index.ts new file mode 100644 index 00000000..1a9c0bce --- /dev/null +++ b/packages/payload-helper/src/fields/index.ts @@ -0,0 +1,4 @@ +export { PublishedAt } from './PublishedAt.js'; +export type { PublishedAtArgs } from './PublishedAt.js'; +export { URLField } from './URLField.js'; +export type { URLFieldArgs } from './URLField.js'; diff --git a/packages/payload-helper/src/index.ts b/packages/payload-helper/src/index.ts index 731dd04c..b21631a3 100644 --- a/packages/payload-helper/src/index.ts +++ b/packages/payload-helper/src/index.ts @@ -46,6 +46,10 @@ export { // Common/Reusable export { SEOFields } from './common/index.js'; +// Fields +export { PublishedAt, URLField } from './fields/index.js'; +export type { PublishedAtArgs, URLFieldArgs } from './fields/index.js'; + // Email Config Helper export { defineEmailConfig } from './email/defineEmailConfig.js';