Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/move-fields-directory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ainsleydev/payload-helper': patch
---

Add PublishedAt and URLField field helpers and expose via ./fields export path
5 changes: 5 additions & 0 deletions .changeset/payload-helper-minor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ainsleydev/sveltekit-helper": minor
---

Add Payload SEO helper components and tidy metadata handling.
4 changes: 4 additions & 0 deletions packages/payload-helper/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/payload-helper/src/common/index.ts
Original file line number Diff line number Diff line change
@@ -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';
130 changes: 130 additions & 0 deletions packages/payload-helper/src/fields/PublishedAt.test.ts
Original file line number Diff line number Diff line change
@@ -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<NonNullable<DateField['hooks']>['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<NonNullable<DateField['hooks']>['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<NonNullable<DateField['hooks']>['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');
});
});
40 changes: 40 additions & 0 deletions packages/payload-helper/src/fields/PublishedAt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { DateField, Field } from 'payload';
import { deepMerge } from 'payload';

export type PublishedAtArgs = {
overrides?: Partial<DateField>;
};

/**
* 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<Field, Partial<DateField>>(baseField, args?.overrides || {});
};
98 changes: 98 additions & 0 deletions packages/payload-helper/src/fields/URLField.test.ts
Original file line number Diff line number Diff line change
@@ -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<TypeWithID>);
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<TypeWithID>);
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<TypeWithID>);
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<TypeWithID>);
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<TypeWithID>);
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',
});
});
});
49 changes: 49 additions & 0 deletions packages/payload-helper/src/fields/URLField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Field, FieldHookArgs, TextField, TypeWithID } from 'payload';
import { deepMerge } from 'payload';

export type URLFieldArgs<T extends TypeWithID> = {
overrides?: Partial<Omit<TextField, 'type'>>;
generate: (args: FieldHookArgs<T>) => string | Promise<string | undefined>;
};

/**
* 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 = <T extends TypeWithID>({ generate, overrides }: URLFieldArgs<T>): Field => {
const baseField: Field = {
name: 'url',
label: 'URL',
type: 'text',
admin: {
readOnly: true,
position: 'sidebar',
},
virtual: true,
hooks: {
afterRead: [
async (args: FieldHookArgs<T>) => {
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<Field, Partial<Omit<TextField, 'type'>>>(baseField, overrides || {});
};
4 changes: 4 additions & 0 deletions packages/payload-helper/src/fields/index.ts
Original file line number Diff line number Diff line change
@@ -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';
4 changes: 4 additions & 0 deletions packages/payload-helper/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading