Skip to content
Draft
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/presets-link-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sanity/presets": minor
---

Add link field preset and preset composer plugin
9 changes: 7 additions & 2 deletions dev/test-studio/src/presets/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {presetsComposer} from '@sanity/presets'
import {linkField, LINK_FIELD_TYPE, presetsComposer} from '@sanity/presets'
import {definePlugin, defineType} from 'sanity'

const corePresetsTest = defineType({
Expand All @@ -11,10 +11,15 @@ const corePresetsTest = defineType({
title: 'Title',
type: 'string',
},
{
name: 'link',
title: 'Link',
type: LINK_FIELD_TYPE,
},
],
})

export const presetsWorkspace = definePlugin(() => ({
schema: {types: [corePresetsTest]},
plugins: [presetsComposer()],
plugins: [presetsComposer([linkField({internalTypes: ['corePresetsTest']})])],
}))
11 changes: 11 additions & 0 deletions plugins/@sanity/presets/src/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`package exports 1`] = `
{
".": {
"LINK_FIELD_TYPE": "string",
"linkField": "function",
"presetsComposer": "function",
},
}
`;
96 changes: 96 additions & 0 deletions plugins/@sanity/presets/src/composer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {defineType} from 'sanity'
import {afterEach, describe, expect, test, vi} from 'vitest'

import {collectTypes, presetsComposer} from './composer'
import type {PresetResult} from './types'

function createPreset(typeNames: string[]): PresetResult {
return {
types: typeNames.map((name) => defineType({name, type: 'object', fields: []})),
}
}

describe('presetsComposer', () => {
afterEach(() => vi.restoreAllMocks())

test('returns plugin with correct name', () => {
const result = presetsComposer([])

expect(result.name).toBe('@sanity/presets')
})

test('includes schema types from presets', () => {
const result = presetsComposer([createPreset(['test.type'])])

expect(result.schema?.types).toEqual([expect.objectContaining({name: 'test.type'})])
})

test('deduplicates types across presets', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const preset = createPreset(['shared.type'])

presetsComposer([preset, preset])

expect(warnSpy).toHaveBeenCalledWith(
'[@sanity/presets] Dropped duplicate type "shared.type". Keeping first definition.',
)
})
})

describe('collectTypes', () => {
afterEach(() => vi.restoreAllMocks())

test('returns empty array for empty input', () => {
const types = collectTypes([])

expect(types).toEqual([])
})

test('deduplicates when the same preset appears multiple times', () => {
const preset = createPreset(['link.type'])

const types = collectTypes([preset, preset])
const typeNames = types.map((type) => type.name)

expect(typeNames).toEqual(['link.type'])
})

test('deduplicates matching type names across presets', () => {
const first = createPreset(['link.type'])
const second = createPreset(['link.type'])

const types = collectTypes([first, second])
const typeNames = types.map((type) => type.name)

expect(typeNames).toEqual(['link.type'])
})

test('deduplicates by type name across presets and warns', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

const presetA = createPreset(['shared.type', 'alpha.type'])
const presetB = createPreset(['shared.type', 'beta.type'])

const types = collectTypes([presetA, presetB])
const typeNames = types.map((type) => type.name)

expect(typeNames).toEqual(['shared.type', 'alpha.type', 'beta.type'])
expect(warnSpy).toHaveBeenCalledWith(
'[@sanity/presets] Dropped duplicate type "shared.type". Keeping first definition.',
)
})

test('keeps unique names and drops duplicates across three presets', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

const types = collectTypes([
createPreset(['core.presets.link', 'core.presets.seo']),
createPreset(['core.presets.link']),
createPreset(['core.presets.link', 'core.presets.seo']),
])
const typeNames = types.map((type) => type.name)

expect(typeNames).toEqual(['core.presets.link', 'core.presets.seo'])
expect(warnSpy).toHaveBeenCalledTimes(3)
})
})
25 changes: 25 additions & 0 deletions plugins/@sanity/presets/src/composer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {definePlugin, type SchemaTypeDefinition} from 'sanity'

import type {PresetResult} from './types'

export function collectTypes(presets: PresetResult[]): SchemaTypeDefinition[] {
const seen = new Set<string>()

return presets.flatMap((preset) =>
preset.types.filter((typeDef) => {
if (seen.has(typeDef.name)) {
console.warn(
`[@sanity/presets] Dropped duplicate type "${typeDef.name}". Keeping first definition.`,
)
return false
}
seen.add(typeDef.name)
return true
}),
)
}

export const presetsComposer = definePlugin<PresetResult[]>((presetFields) => ({
name: '@sanity/presets',
schema: {types: collectTypes(presetFields)},
}))
8 changes: 1 addition & 7 deletions plugins/@sanity/presets/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,5 @@ test('package exports', {timeout: 30_000}, async () => {
cwd: fileURLToPath(import.meta.url),
})

expect(manifest.exports).toMatchInlineSnapshot(`
{
".": {
"presetsComposer": "function",
},
}
`)
expect(manifest.exports).toMatchSnapshot()
})
11 changes: 4 additions & 7 deletions plugins/@sanity/presets/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import {definePlugin} from 'sanity'

export function presetsComposer() {
return definePlugin({
name: '@sanity/presets',
})()
}
export {presetsComposer} from './composer'
export {linkField, LINK_FIELD_TYPE} from './presets/link-field'
export type {LinkFieldConfig} from './presets/link-field'
export type {PresetResult} from './types'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const LINK_FIELD_TYPE = 'core.presets.link'
18 changes: 18 additions & 0 deletions plugins/@sanity/presets/src/presets/link-field/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type {PresetResult} from '../../types'
import {createLinkFieldType} from './schema'

export {LINK_FIELD_TYPE} from './constants'

export interface LinkFieldConfig {
internalTypes: string[]
}

export function linkField(config: LinkFieldConfig): PresetResult {
if (config.internalTypes.length === 0) {
throw new Error('[@sanity/presets] linkField requires at least one internalTypes entry.')
}

return {
types: [createLinkFieldType(config.internalTypes)],
}
}
172 changes: 172 additions & 0 deletions plugins/@sanity/presets/src/presets/link-field/link-field.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type {FieldDefinition, PreviewValue} from 'sanity'
import {describe, expect, test} from 'vitest'

import {LINK_FIELD_TYPE} from './constants'
import {linkField} from './index'

const defaultConfig = {internalTypes: ['page']}

function getFields(result: ReturnType<typeof linkField>): FieldDefinition[] {
const typeDef = result.types[0]
if (!typeDef || !('fields' in typeDef) || !typeDef.fields) {
throw new Error('Expected an object type definition with fields')
}
return typeDef.fields
}

function getField(fields: FieldDefinition[], name: string): FieldDefinition {
const field = fields.find((entry) => entry.name === name)
if (!field) {
throw new Error(`Field "${name}" not found`)
}
return field
}

function evaluateHidden(field: FieldDefinition, parent: Record<string, unknown>): unknown {
if (typeof field.hidden === 'function') {
return field.hidden({
parent,
document: {_id: 'test', _type: 'test', _createdAt: '', _updatedAt: '', _rev: ''},
currentUser: null,
value: undefined,
path: [],
})
}
return field.hidden
}

function callPrepare(
result: ReturnType<typeof linkField>,
selection: Record<string, unknown>,
): PreviewValue {
const typeDef = result.types[0]
const prepare = typeDef && 'preview' in typeDef ? typeDef.preview?.prepare : undefined
if (!prepare) throw new Error('Expected preview.prepare on type definition')

return prepare(selection)
}

describe('linkField', () => {
test('returns one type named core.presets.link', () => {
const result = linkField(defaultConfig)

expect(result.types).toHaveLength(1)
expect(result.types[0]?.name).toBe(LINK_FIELD_TYPE)
})

test('type is an object with 4 fields', () => {
const fields = getFields(linkField(defaultConfig))

expect(fields).toHaveLength(4)

const fieldNames = fields.map((field) => field.name)
expect(fieldNames).toEqual(['linkType', 'reference', 'url', 'openInNewTab'])
})

test('throws when internalTypes is empty', () => {
expect(() => linkField({internalTypes: []})).toThrow(
'[@sanity/presets] linkField requires at least one internalTypes entry.',
)
})

test('maps internalTypes to reference targets', () => {
const fields = getFields(linkField({internalTypes: ['page', 'post']}))
const referenceField = getField(fields, 'reference')

expect(referenceField).toHaveProperty('to', [{type: 'page'}, {type: 'post'}])
})

test('hidden callbacks show correct fields for internal type', () => {
const fields = getFields(linkField(defaultConfig))
const internalParent = {linkType: 'internal'}

expect(evaluateHidden(getField(fields, 'reference'), internalParent)).toBe(false)
expect(evaluateHidden(getField(fields, 'url'), internalParent)).toBe(true)
expect(evaluateHidden(getField(fields, 'openInNewTab'), internalParent)).toBe(true)
})

test('hidden callbacks show correct fields for external type', () => {
const fields = getFields(linkField(defaultConfig))
const externalParent = {linkType: 'external'}

expect(evaluateHidden(getField(fields, 'reference'), externalParent)).toBe(true)
expect(evaluateHidden(getField(fields, 'url'), externalParent)).toBe(false)
expect(evaluateHidden(getField(fields, 'openInNewTab'), externalParent)).toBe(false)
})

test('hidden callbacks show conditional fields when linkType is undefined', () => {
const fields = getFields(linkField(defaultConfig))
const emptyParent = {linkType: undefined}

expect(evaluateHidden(getField(fields, 'reference'), emptyParent)).toBe(false)
expect(evaluateHidden(getField(fields, 'url'), emptyParent)).toBe(false)
expect(evaluateHidden(getField(fields, 'openInNewTab'), emptyParent)).toBe(false)
})
})

describe('linkField preview.select', () => {
test('selects correct paths for preview', () => {
const typeDef = linkField(defaultConfig).types[0]
const select = typeDef && 'preview' in typeDef ? typeDef.preview?.select : undefined

expect(select).toEqual({
linkType: 'linkType',
url: 'url',
referenceTitle: 'reference.title',
})
})
})

describe('linkField preview.prepare', () => {
const preset = linkField(defaultConfig)

test('internal link with a reference title', () => {
const result = callPrepare(preset, {
linkType: 'internal',
referenceTitle: 'About Us',
url: undefined,
})

expect(result).toEqual({title: 'About Us', subtitle: 'Internal link'})
})

test('internal link without reference title shows fallback', () => {
const result = callPrepare(preset, {
linkType: 'internal',
referenceTitle: undefined,
url: undefined,
})

expect(result).toEqual({title: 'No reference', subtitle: 'Internal link'})
})

test('external link with a URL', () => {
const result = callPrepare(preset, {
linkType: 'external',
url: 'https://example.com',
referenceTitle: undefined,
})

expect(result).toEqual({title: 'https://example.com', subtitle: 'External link'})
})

test('external link without URL shows fallback', () => {
const result = callPrepare(preset, {
linkType: 'external',
url: undefined,
referenceTitle: undefined,
})

expect(result).toEqual({title: 'No URL', subtitle: 'External link'})
})

test('undefined linkType defaults to internal link fallback', () => {
const result = callPrepare(preset, {
linkType: undefined,
url: undefined,
referenceTitle: undefined,
})

expect(result).toEqual({title: 'No reference', subtitle: 'Internal link'})
})
})
Loading
Loading