Skip to content

feat(@sanity/presets): add map methods for overriding any preset-created property#788

Open
juice49 wants to merge 1 commit intomainfrom
feat/presets-extensibility
Open

feat(@sanity/presets): add map methods for overriding any preset-created property#788
juice49 wants to merge 1 commit intomainfrom
feat/presets-extensibility

Conversation

@juice49
Copy link
Copy Markdown
Contributor

@juice49 juice49 commented Mar 27, 2026

Description

This branch adds capabilities for extending presets.

  1. Presets now accept schema type config reflecting the type they alias. For example, the SEO and Page types accept fields and groups config. These properties shadow the config users would typically pass to the defineType helpers. When a user provides a fields or groups array, the values are appended to the values created by the preset.
    • Users may never override the type config.
    • If a preset doesn't allow a config to be extended in this way, it can prevent users from setting it by adding its name to the LockedProperties type parameter of the definePresetType function. Users will not longer be permitted to set this property.
    • Any config the user provides that does not intersect with properties the preset itself defines should be spread directly into the preset's defineType call.
  2. All presets now have a map config. This is an object that provides callbacks that can override any config property set by the preset. This is an escape hatch for users who need to modify a config property created by the preset.
    • For example, this allows a user to prepend rather than append a field.

Example: append a field to a page using fields

pageType({
  pageBuilderBlocks: ['core.presets.cta', 'blockquote'],
  fields: [
    defineField({
      name: 'extension',
      type: 'string',
      group: 'main',
    }),
  ],
}),

Example: prepend a field to a page using map.fields

pageType({
  pageBuilderBlocks: ['core.presets.cta', 'blockquote'],
  map: {
    fields: (fields = []) => [
      defineField({
        name: 'extension',
        type: 'string',
        group: 'main',
      }),
      ...fields,
    ],
  },
}),

What to review

Extending and modifying presets: do these new APIs feel right?

Testing

Not added extensive tests yet as aiming to quickly prove out the API.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 27, 2026

🦋 Changeset detected

Latest commit: ed28232

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@sanity/presets Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
plugins-studio Ready Ready Preview, Comment Apr 9, 2026 1:22pm

Request Review

@juice49 juice49 force-pushed the feat/presets-extensibility branch from 6631734 to 3309d26 Compare March 27, 2026 15:22
@juice49 juice49 force-pushed the feat/presets-extensibility branch from 3309d26 to 7741ce6 Compare March 27, 2026 15:24
@juice49 juice49 force-pushed the feat/presets-extensibility branch from 7741ce6 to ffe2875 Compare March 27, 2026 15:58
@juice49 juice49 marked this pull request as ready for review March 27, 2026 16:47
@juice49 juice49 requested a review from a team as a code owner March 27, 2026 16:47
@juice49 juice49 requested review from bjoerge and jordanl17 and removed request for a team March 27, 2026 16:47
@jordanl17
Copy link
Copy Markdown
Member

Thinking about the API shape overall - the layered approach (direct props, rest spread, map) makes sense as progressive disclosure, and the type machinery around LockedProperties is nice.

One thing I keep coming back to: fields is append-only, which means inserting a field between preset fields (e.g., between name and slug) requires map.fields and rebuilding the whole array. That's a pretty common use case for someone extending a page type, so it makes map load-bearing for day-to-day work rather than a genuine escape hatch.

Would it be worth letting fields accept a function too? eg

    pageType({
      pageBuilderBlocks: ['core.presets.cta'],
      fields: (presetFields) => [
        ...presetFields.slice(0, 1),
        defineField({ name: 'subtitle', type: 'string', group: 'main' }),
        ...presetFields.slice(1),
      ],
    })

That way the simple case stays simple (pass an array, it appends), but the common "I need to control position" case doesn't require reaching for map. It'd narrow map to truly unusual overrides - changing preview, options, etc. - which feels more like an escape hatch.

Minor naming thought: map reads like "transform each element of a collection" in JS. Something like transform or override might signal the intent more clearly.

Copy link
Copy Markdown
Member

@jordanl17 jordanl17 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Do you think the tests would be doable as a fast follow PR after this is merged? Just consious we don't want to proceed too far ahead of our test coverage as we might end up missing it.

@juice49
Copy link
Copy Markdown
Contributor Author

juice49 commented Apr 7, 2026

Nice! Do you think the tests would be doable as a fast follow PR after this is merged?

@jordanl17 yes, we absolutely can, and will. My goal was to first quickly iterate on the API and see whether it felt right.

@juice49
Copy link
Copy Markdown
Contributor Author

juice49 commented Apr 7, 2026

Thinking about the API shape overall - the layered approach (direct props, rest spread, map) makes sense as progressive disclosure, and the type machinery around LockedProperties is nice.

One thing I keep coming back to: fields is append-only, which means inserting a field between preset fields (e.g., between name and slug) requires map.fields and rebuilding the whole array. That's a pretty common use case for someone extending a page type, so it makes map load-bearing for day-to-day work rather than a genuine escape hatch.

Would it be worth letting fields accept a function too? eg

    pageType({
      pageBuilderBlocks: ['core.presets.cta'],
      fields: (presetFields) => [
        ...presetFields.slice(0, 1),
        defineField({ name: 'subtitle', type: 'string', group: 'main' }),
        ...presetFields.slice(1),
      ],
    })

That way the simple case stays simple (pass an array, it appends), but the common "I need to control position" case doesn't require reaching for map. It'd narrow map to truly unusual overrides - changing preview, options, etc. - which feels more like an escape hatch.

Minor naming thought: map reads like "transform each element of a collection" in JS. Something like transform or override might signal the intent more clearly.

@jordanl17 I worry the types and usage start to get a bit murky here if we make special exceptions for specific options.

My original plan was actually to make any option accept a function in addition to its native type, but the problem with that approach is that some native types are functions (e.g. validation).

Maybe this is a sign we haven't gotten the API quite right. Let's talk more about this.

Minor naming thought: map reads like "transform each element of a collection" in JS. Something like transform or override might signal the intent more clearly.

Studio does have some precedence for this naming convention: when duplicating a document, it supports a mapDocument option that receives the target document and returns a new one. I tend to think of any value being mappable; a map being an operation that can transform one or many values, but I can see this understanding varying with different backgrounds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants