Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds support for Create-Studio integration #7635

Merged
merged 46 commits into from
Oct 25, 2024
Merged

Conversation

snorrees
Copy link
Contributor

@snorrees snorrees commented Oct 19, 2024

Description

This PR introduces the Studio side of the upcoming Create <-> Studio integration.

It allows users start their content editing journey in Create for new documents, by linking a Create document to a Studio document.

While linked, Create will sync content into the Studio document in real-time. Create linked Studio documents are read-only. Studio users can unlink the document at any point, to continue editing in the Studio. At this point Create will no longer sync content into the Studio document.

Update: Now sporting a beauty pass by @robinpyon 🙇

Update: beta.sanity.startInCreateEnabled will default to false. Ie, the "Start in Sanity Create" button will be OPT IN for now. Documents that get linked from the Create side will still be put into read-only in the Studio. (this is also a form of opt-in tbh).

What to review

Read through this full PR text before jumping into the code.
Changes affect:

  • core
    • change to userStore needs extra attention (This is no longer relevant, see below)
  • structure
  • schemaType options

There might be some controversial changes in here. I have made PR comments everywhere I though there might be some eyebrows raised, but there might be more. I'm very much open to course corrections.

Feature details

For Sanity folks: first see video of the full Studio-Create flow here.

In Studio deployments with an exposed create-manifest.json, new, prestine documents will have a "Start in Sanity Create" button in the document pane footer. See previous manifest PR for context.

image

Clicking it will opens a confirmation dialog:

image

Clicking "Learn more" will open a "How to Create<->Studio" article. (Link url pending)

Clicking "Continue" will open a new tab which kicks you off to Create which will:

  • Create a new document
  • Populate it with some initial data
  • Link the Create document to the Studio document

A studio document is considered "Create linked" whilst it has _create.ejected === false.

While Create setting up the link (which entails setting the _create property), the following dialog will be shown after clicking "Continue":

image
(displaying open state of the toggle)

Once the link is established, the document will be readOnly and the pane footer will be in a special "Create linked" mode:

image

Clicking the info icon will open a popover:

image

Clicking anywhere outside (or pressing ESC) closes the popover.

Clicking "Edit in Sanity Create" will open up the Create document that points to this Studio document in a new tab.

Clicking "Unlink" will show the following dialog:

image

"Unlink now" will unset the _create property, thereby severing the link and making the document a regular Studio document. It will no longer be readOnly.

High level implementation details

Code organization

I tried to:

  1. implement as much functionally as possible using our public plugin APIs
  2. keep as much Create integration code under the same directory as possible
  3. Build on existing principles when extending type options

For 1: this feature has some requirements that were not 100% plugin-supported, and required changes in core and structure:

  • document actions sort order
  • a clean way to put a pane-wide banner under the document pane header
  • a way to force certain "document footer actions" when Create linked

For 2: I had to introduce a bit of indirection to make it possible to render stuff in structure. SanityCreateConfigContextcontains two component implementations used by structure, provided from core.
These are the components rendered when a document is Create linked. (Read only banner & Unlink actions).

For 3: I have added a BaseSchemaTypeOptions type. I always regretted not adding that for the initial v3 launch, since it allows plugins to add generic type-options much more easily. For instance, it could simplify the AI Assist type extensions quite a bit.

SanityCreateOptions

I have added sanityCreate options directly to BaseSchemaTypeOptions. If it feels controversial to have them there, I can move them into a module declaration extension in core, but I feel that is only adding indirection with no upside.

The idea is that SanityCreateOptions will be the DSL with which devs tailor their schema in Create (via the manifest file).
Atm it supports exclude and purpose

  • exclude removes a type or field from appearing in Create. Excluded document types will not have a "Start in Sanity Create" button.
  • purpose supersedes description, and will be used as metadata when describing the schema to an LLM

We need different options from AI Assist (which also has this exclude option), as Create has different needs than AI Assist.

Start in Create

We show the "Start in Sanity Create" button when:

  • beta.create.startInCreateEnabled: true. Atm, startInCreateEnabled defaults to false. This means studios will have to OPT IN.
  • the document is new (ie, no _createdAt)
  • the current browser origin matches a Studio origin found in Studios for the current project (sanity.io/manage)
    • exception: developers can provide a fallbackStudioOrigin to make the button appear on localhost
    • why is origin important?: Create uses Studio appId when preparing the studio link; it needs to import the schema from create-manifest-json on a known url. It will only visit hosts listed under Studios in manage.
  • documentOptions.sanityCreate.exclude !== true

Create link

A Studio document is considered linked to Create when it contains a _create metadata field.

Specifically we check for _create.ejected === false. The idea is that we can keep the Create metadata around if we want to, but in this implementation we unset the full _create field when unlinking.
This ensures that this metadata does not end up in published documents.

If we want to do "soft unlinks" to keep a trace back to Create in the future (possibly opt-in via config), we can do that with with _create.ejected: true.

Create link readOnly mode will always be enabled, regardless of what beta.create.startInCreateEnabled is.

Getting the global user id

Edit: We found issues with this approach and will remove the "global-id-in-url" approach.
As it turns out the user can log in with a different user in Create, resulting in the link-creation failing. Instead Create will handle all this, so we no longer need global user id in Studio.

To build the "Start in Sanity Create" url, we need the global user id.
For current user, this is currently not available – but it IS for all other userIds. I propose a change to that, by no longer pre-priming the userStore with currentUser (at the cost of one more network request).

See PR code comment for change to userStore.

App-id cache

To build "Start in Sanity Create" url, we need the appId (deployed studio id) from <project>/user-applications.
It is ok to cache this info for the duration of the Studio lifetime (ie, until browser refresh).

I dont know if there are existing caching mechanisms in core I could use instead of rollinga bespoke fetch-cache for this.

Telemetry

I've added telemetry for the following actions:

  • Start in Create clicked
  • Start in Create accepted (clicking continue or auto-accept is the same event)
  • Unlink clicked
  • Unlink accepted
  • Edit in Create clicked

Open questions

  • Do these code-paths need e2e tests?

Known caveats

Create does not respect initialValues, so these will be nuked when Create starts syncing.
We might support that when we implement "Continue in Create from here" or something. But that is for another day.

Testing

For Sanity folks: follow the instructions here before starting .

Locally this integration can be tested by running the new test studio: dev/test-create-integration-studio. It has fallbackOrigin set, and will therefor have "Start in Sanity Create" on localhost.

The integration can also be tested on https://create-integration-test.sanity.studio – it has been deployed from this branch.

Notes for release

The Create <-> Studio integration needs a full documentation article. There are a lot of moving parts.

Since beta.sanity.startInCreateEnabled will default to false. Ie, the "Start in Sanity Create" button will be OPT IN, we can stealth launch this feature.

Documents that get linked from the Create side will still be put into read-only in the Studio. If this happens, the editor has access to Create mapping and have probably read whatever Create-Studio documentation we have put out.

For everyone else, this PR effectively is a no-op.

TL;DR: We dont need release notes when this goes out with core. We will talk about Create-Studio at the next event, and then we can do something, as needed.

Copy link

vercel bot commented Oct 19, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
page-building-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Oct 24, 2024 8:11pm
performance-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Oct 24, 2024 8:11pm
test-compiled-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Oct 24, 2024 8:11pm
test-next-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Oct 24, 2024 8:11pm
test-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Oct 24, 2024 8:11pm
1 Skipped Deployment
Name Status Preview Comments Updated (UTC)
studio-workshop ⬜️ Ignored (Inspect) Visit Preview Oct 24, 2024 8:11pm

Copy link
Contributor

No changes to documentation

Copy link
Contributor

github-actions bot commented Oct 19, 2024

Component Testing Report Updated Oct 24, 2024 8:07 PM (UTC)

❌ Failed Tests (1) -- expand for details
File Status Duration Passed Skipped Failed
comments/CommentInput.spec.tsx ✅ Passed (Inspect) 45s 15 0 0
formBuilder/ArrayInput.spec.tsx ✅ Passed (Inspect) 9s 3 0 0
formBuilder/inputs/PortableText/Annotations.spec.tsx ✅ Passed (Inspect) 31s 6 0 0
formBuilder/inputs/PortableText/copyPaste/CopyPaste.spec.tsx ✅ Passed (Inspect) 38s 11 7 0
formBuilder/inputs/PortableText/copyPaste/CopyPasteFields.spec.tsx ✅ Passed (Inspect) 0s 0 12 0
formBuilder/inputs/PortableText/Decorators.spec.tsx ✅ Passed (Inspect) 18s 6 0 0
formBuilder/inputs/PortableText/DisableFocusAndUnset.spec.tsx ✅ Passed (Inspect) 11s 3 0 0
formBuilder/inputs/PortableText/DragAndDrop.spec.tsx ✅ Passed (Inspect) 3m 0s 0 0 0
formBuilder/inputs/PortableText/FocusTracking.spec.tsx ✅ Passed (Inspect) 45s 15 0 0
formBuilder/inputs/PortableText/Input.spec.tsx ❌ Failed (Inspect) 1m 42s 20 0 1
formBuilder/inputs/PortableText/ObjectBlock.spec.tsx ✅ Passed (Inspect) 1m 17s 18 0 0
formBuilder/inputs/PortableText/PresenceCursors.spec.tsx ✅ Passed (Inspect) 9s 3 9 0
formBuilder/inputs/PortableText/RangeDecoration.spec.tsx ✅ Passed (Inspect) 27s 9 0 0
formBuilder/inputs/PortableText/Styles.spec.tsx ✅ Passed (Inspect) 18s 6 0 0
formBuilder/inputs/PortableText/Toolbar.spec.tsx ✅ Passed (Inspect) 37s 12 0 0
formBuilder/tree-editing/TreeEditing.spec.tsx ✅ Passed (Inspect) 0s 0 3 0
formBuilder/tree-editing/TreeEditingNestedObjects.spec.tsx ✅ Passed (Inspect) 0s 0 3 0

Copy link
Contributor

github-actions bot commented Oct 19, 2024

⚡️ Editor Performance Report

Updated Thu, 24 Oct 2024 20:21:04 GMT

Benchmark reference
latency of sanity@latest
experiment
latency of this branch
Δ (%)
latency difference
article (title) 16.9 efps (59ms) 15.5 efps (65ms) +6ms (+9.3%)
article (body) 58.0 efps (17ms) 61.5 efps (16ms) -1ms (-5.8%)
article (string inside object) 18.2 efps (55ms) 16.7 efps (60ms) +5ms (+9.1%)
article (string inside array) 14.4 efps (70ms) 13.2 efps (76ms) +7ms (+9.4%)
recipe (name) 29.4 efps (34ms) 28.6 efps (35ms) +1ms (+2.9%)
recipe (description) 34.5 efps (29ms) 32.3 efps (31ms) +2ms (+6.9%)
recipe (instructions) 99.9+ efps (6ms) 99.9+ efps (7ms) +1ms (-/-%)
synthetic (title) 14.5 efps (69ms) 14.3 efps (70ms) +1ms (+1.4%)
synthetic (string inside object) 14.8 efps (68ms) 14.8 efps (68ms) +0ms (-/-%)

efps — editor "frames per second". The number of updates assumed to be possible within a second.

Derived from input latency. efps = 1000 / input_latency

Detailed information

🏠 Reference result

The performance result of sanity@latest

Benchmark latency p75 p90 p99 blocking time test duration
article (title) 59ms 62ms 69ms 266ms 400ms 15.2s
article (body) 17ms 20ms 41ms 177ms 328ms 6.0s
article (string inside object) 55ms 57ms 63ms 201ms 248ms 8.3s
article (string inside array) 70ms 76ms 114ms 222ms 1138ms 9.8s
recipe (name) 34ms 35ms 45ms 70ms 0ms 9.1s
recipe (description) 29ms 31ms 35ms 51ms 1ms 5.9s
recipe (instructions) 6ms 8ms 8ms 9ms 0ms 3.2s
synthetic (title) 69ms 72ms 78ms 332ms 1422ms 17.4s
synthetic (string inside object) 68ms 73ms 78ms 364ms 991ms 10.0s

🧪 Experiment result

The performance result of this branch

Benchmark latency p75 p90 p99 blocking time test duration
article (title) 65ms 69ms 109ms 167ms 767ms 16.2s
article (body) 16ms 18ms 22ms 254ms 428ms 5.6s
article (string inside object) 60ms 65ms 73ms 195ms 387ms 8.8s
article (string inside array) 76ms 83ms 98ms 346ms 1415ms 10.6s
recipe (name) 35ms 39ms 57ms 82ms 34ms 9.1s
recipe (description) 31ms 33ms 37ms 59ms 1ms 6.0s
recipe (instructions) 7ms 9ms 10ms 47ms 0ms 3.4s
synthetic (title) 70ms 74ms 85ms 139ms 1623ms 16.1s
synthetic (string inside object) 68ms 75ms 82ms 629ms 1777ms 10.9s

📚 Glossary

column definitions

  • benchmark — the name of the test, e.g. "article", followed by the label of the field being measured, e.g. "(title)".
  • latency — the time between when a key was pressed and when it was rendered. derived from a set of samples. the median (p50) is shown to show the most common latency.
  • p75 — the 75th percentile of the input latency in the test run. 75% of the sampled inputs in this benchmark were processed faster than this value. this provides insight into the upper range of typical performance.
  • p90 — the 90th percentile of the input latency in the test run. 90% of the sampled inputs were faster than this. this metric helps identify slower interactions that occurred less frequently during the benchmark.
  • p99 — the 99th percentile of the input latency in the test run. only 1% of sampled inputs were slower than this. this represents the worst-case scenarios encountered during the benchmark, useful for identifying potential performance outliers.
  • blocking time — the total time during which the main thread was blocked, preventing user input and UI updates. this metric helps identify performance bottlenecks that may cause the interface to feel unresponsive.
  • test duration — how long the test run took to complete.

packages/@sanity/types/src/user/types.ts Outdated Show resolved Hide resolved
config,
context: {...context, ...partialContext},
initialValue: initialDocumentActions,
propertyName: 'document.actions',
reducer: documentActionsReducer,
}),
})
return getStartInCreateSortedActions(actions)
Copy link
Contributor Author

@snorrees snorrees Oct 19, 2024

Choose a reason for hiding this comment

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

This is controversial, but necessary: It forces "Start in Create" action to be first in the actions list, when present.

This again results in "Start in Create" being the primary action, and thus shown in the document footer, outside the action-overflow menu.

If a plugin or studio config filters out the action, this sort does nothing.

Alternative implementation suggestions welcome.

Copy link
Member

Choose a reason for hiding this comment

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

Ultimately a product question, but this happens only if 1) create integration is enabled in studio config and 2) only for new documents, right? If that's the case it seems fine to me

Copy link
Member

Choose a reason for hiding this comment

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

(nvm, I see you called that out already in the PR description)

Copy link
Contributor

Choose a reason for hiding this comment

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

My only thought as someone that doesn't know this part of the code super well is that some folks might wonder why, when trying to set a primary action themselves, they can't do it because the link always goes first.

I will say that it might be that you all have set up an exception for this and the use case, in itself, is an edge case that is out of scope for now.

Copy link
Contributor Author

@snorrees snorrees Oct 24, 2024

Choose a reason for hiding this comment

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

This is entirely intentional, since Product wants to faceplant this feature 🤷

Devs have two options:

  • disable start in create
  • OR filter out the action using actions: prev => {}.

Either approach will have the same result: No start in create button.
If you know how to do this via the config api I'm all ears though! I would love for this to end up first, but be moveable into the menu.

In a perfect world the plugin api had more expressive power, where we could set priority on actions or something, which would allow overriding when you really want to.

The alternative to this is to do something more drastic like I had to do for "Open in Sanity Create" and "unlink", ie a completly different codepath in structure. That, imo, is even worse of a smell.

Copy link
Contributor

Choose a reason for hiding this comment

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

No no that does sound worse.
I unfortunately don't know how to do it off the top of my head since I don't know that part of the code that well. I'm assuming there is a way of doing: "if there is a custom primary action, then apply that first, otherwise do the open in sanity". 🤔

However, this is non-blocking because, like you mentioned, folks can disable it if they want or they can filter it out

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just doing some final tests, and there is actually a (hacky) way to move the action into the menu.
The gist is: on the root defineConfig, intercept the "Start in Sanity Create" action, and remove the action prop! 🙈

  document: {
    actions: (prev) => {
      return prev.map((a) => {
        if (a.action !== ('startInCreate' as any)) {
          return a
        }
        //returs a new function that does not have a action prop
        return (props) => {
          return a(props)
        }
      })
    },
  },

image

Yup, its pretty dumb, but it can be done ;)

return {
...beta?.create,
startInCreateEnabled: !!beta?.create?.startInCreateEnabled,
components: {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Notice how we provide structure components from core via context here.
Downside: introduces indirection
Upside: keeps most Create integration code in a directory structure in core

Copy link
Contributor

@RitaDias RitaDias Oct 24, 2024

Choose a reason for hiding this comment

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

I think my only concern about this is what I have commented previously about the banners.

Technically you could also export these directly from the sanity package (and then use them wherever you need to like the structure). Is there a reason why that wasn't considered vs this approach? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I got to admit , I did not consider that approach. I've tried to limit what gets exported, as not to pollute the global `sanity´ api, was the thought. Therefore, only the props are exported, ie the interface. The implementations are internal.

I'd be happy to export the components, and ditch the context approach if you prefer.
To me, it just seems non-idiomatic to keep implementations without an interface in one package and use directly in another. I realize now that the way I'm going about this might break established sanity conventions though, so I'm happy to take your lead on this.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think there is a conventional way per se of doing things; think of it more as a "mindless way that we've been going about it" than anything intentional :)

I just wanted to double check. I don't have a strong opinion about the way of exporting in particular and I'd be ok with this approach :)

import {createStartInCreateAction} from './start-in-create/StartInCreateAction'
import {createAppIdCache} from './studio-app/appIdCache'

export const createIntegration = definePlugin(() => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not everything needed for the Create integration can be expressed via the plugin API.
As much as possible has been put in here though. If anything else in this PR can be put here via existing apis, please let me know.

export const START_IN_CREATE_ACTION_NAME =
'startInCreate' as unknown as DocumentActionComponent['action']

export function createStartInCreateAction(appIdCache: AppIdCache): DocumentActionComponent {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, yes. Create is such a great app name :/

}
export type AppIdFetcher = (projectId: string) => Promise<string | undefined>

export function createAppIdCache(): AppIdCache {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unclear to me if there are existing caching code available in the sanity package that I should rather be using.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think there is 🤔

packages/sanity/src/core/store/_legacy/user/userStore.ts Outdated Show resolved Hide resolved
@@ -533,7 +535,8 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => {
isLocked ||
isDeleting ||
isDeleted ||
isLiveEditAndDraft
isLiveEditAndDraft ||
isCreateLinked
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not clear from the diff here, but this sets the documentPane readOnly property.

Note: Not all codepaths respect document path readOnlystate. For instance, Paste document can mutate a document pane in readOnly mode. I contend that this is the right place to put this readOnly code for Create though; code that does not respect it should be corrected elsewhere.

@@ -85,6 +88,9 @@ export function DocumentLayout() {
const zOffsets = useZIndex()
const previewUrl = usePreviewUrl(value)

const createLinkMetadata = getCreateLinkMetadata(value)
const CreateLinkedBanner = useSanityCreateConfig().components?.documentLinkedBanner
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is done to avoid having to put component code belonging to the Create integration in the structure package.

const {editState, timelineStore, onChange: onDocumentChange} = useDocumentPane()
const {title} = useDocumentTitle()

const CreateLinkedActions = useSanityCreateConfig().components?.documentLinkedActions
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is done to avoid having to put component code belonging to the Create integration in the structure package.

@snorrees
Copy link
Contributor Author

snorrees commented Oct 24, 2024

@RitaDias I'm happy to report that beta.sanity.startInCreateEnabled will ship false as the default. Ie, it will be OPT IN to have the "Start in Create" button.

Copy link
Contributor

@RitaDias RitaDias left a comment

Choose a reason for hiding this comment

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

Great work :)

@snorrees snorrees added this pull request to the merge queue Oct 25, 2024
Merged via the queue into next with commit 12cb46b Oct 25, 2024
70 checks passed
@snorrees snorrees deleted the create-integration branch October 25, 2024 11:31
bjoerge added a commit that referenced this pull request Oct 28, 2024
* next:
  feat: adds support for Create-Studio integration (#7635)
  fix(deps): Update dev-non-major (#7671)
  fix(deps): update dependency @sanity/insert-menu to v1.0.10 (#7668)
  fix(deps): update dependency @sanity/presentation to v1.17.6 (#7669)
  v3.62.2
  fix(deps): Update dev-non-major (#7663)
  fix(deps): update dependency @sanity/presentation to v1.17.4 (#7662)
  fix(deps): Update dev-non-major (#7661)
  v3.62.1
  feat(cli): add warning and docs for react-19 and Next.Js combined (#7660)
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.

6 participants