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

Draft
wants to merge 19 commits into
base: next
Choose a base branch
from

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..

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 open the following dialog (gfx pending):

image

Clicking "Learn more" will open a "How to Create<->Studio" article.

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" (copy pending):

image

On 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 (gfx pending).

Hover tooltip:
image

Click popover:
image

Clicking anywhere outside 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 true. We might want to flip this to false before merging, depending on what Product wants.
  • 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.

I will update this PR once the Create side supports this behavior.

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.

TODOs before opening the PR for review

  • add unit tests to easily testable code
  • address all my own PR todos

Testing

For Sanity folks: follow the instructions here before starting .

Beware: Cannot be fully tested until we release the Create side of the integration (Studio link/content mapping).

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.
Depending on when this goes out, we might want to stealth launch it without a release note.

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 21, 2024 11:29am
performance-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Oct 21, 2024 11:29am
test-compiled-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Oct 21, 2024 11:29am
test-next-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Oct 21, 2024 11:29am
test-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Oct 21, 2024 11:29am
1 Skipped Deployment
Name Status Preview Comments Updated (UTC)
studio-workshop ⬜️ Ignored (Inspect) Visit Preview Oct 21, 2024 11:29am

Copy link
Contributor

No changes to documentation

Copy link
Contributor

github-actions bot commented Oct 19, 2024

Component Testing Report Updated Oct 21, 2024 11:25 AM (UTC)

✅ All Tests Passed -- expand for details
File Status Duration Passed Skipped Failed
comments/CommentInput.spec.tsx ✅ Passed (Inspect) 46s 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) 17s 6 0 0
formBuilder/inputs/PortableText/DisableFocusAndUnset.spec.tsx ✅ Passed (Inspect) 10s 3 0 0
formBuilder/inputs/PortableText/DragAndDrop.spec.tsx ✅ Passed (Inspect) 3m 0s 0 0 0
formBuilder/inputs/PortableText/FocusTracking.spec.tsx ✅ Passed (Inspect) 46s 15 0 0
formBuilder/inputs/PortableText/Input.spec.tsx ✅ Passed (Inspect) 1m 43s 21 0 0
formBuilder/inputs/PortableText/ObjectBlock.spec.tsx ✅ Passed (Inspect) 1m 19s 18 0 0
formBuilder/inputs/PortableText/PresenceCursors.spec.tsx ✅ Passed (Inspect) 9s 3 9 0
formBuilder/inputs/PortableText/RangeDecoration.spec.tsx ✅ Passed (Inspect) 26s 9 0 0
formBuilder/inputs/PortableText/Styles.spec.tsx ✅ Passed (Inspect) 19s 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 Mon, 21 Oct 2024 11:28:12 GMT

Benchmark reference
latency of sanity@latest
experiment
latency of this branch
Δ (%)
latency difference
article (title) 16.9 efps (59ms) 15.2 efps (66ms) +7ms (+11.9%)
article (body) 53.5 efps (19ms) 53.6 efps (19ms) -0ms (-0.3%)
article (string inside object) 18.2 efps (55ms) 16.7 efps (60ms) +5ms (+9.1%)
article (string inside array) 14.8 efps (68ms) 13.3 efps (75ms) +8ms (+11.1%)
recipe (name) 31.3 efps (32ms) 30.3 efps (33ms) +1ms (+3.1%)
recipe (description) 34.5 efps (29ms) 33.3 efps (30ms) +1ms (+3.4%)
recipe (instructions) 99.9+ efps (6ms) 99.9+ efps (7ms) +1ms (-/-%)
synthetic (title) 14.8 efps (68ms) 14.2 efps (71ms) +3ms (+4.4%)
synthetic (string inside object) 14.6 efps (69ms) 14.4 efps (70ms) +1ms (+1.5%)

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 64ms 72ms 237ms 367ms 12.7s
article (body) 19ms 21ms 34ms 251ms 296ms 6.0s
article (string inside object) 55ms 58ms 62ms 164ms 213ms 8.5s
article (string inside array) 68ms 73ms 82ms 297ms 865ms 9.5s
recipe (name) 32ms 35ms 46ms 87ms 0ms 9.1s
recipe (description) 29ms 32ms 35ms 69ms 0ms 6.0s
recipe (instructions) 6ms 9ms 10ms 34ms 0ms 3.3s
synthetic (title) 68ms 70ms 141ms 383ms 1500ms 15.1s
synthetic (string inside object) 69ms 78ms 94ms 506ms 1849ms 10.9s

🧪 Experiment result

The performance result of this branch

Benchmark latency p75 p90 p99 blocking time test duration
article (title) 66ms 72ms 125ms 276ms 631ms 14.6s
article (body) 19ms 23ms 56ms 121ms 265ms 6.5s
article (string inside object) 60ms 63ms 74ms 201ms 324ms 9.0s
article (string inside array) 75ms 79ms 91ms 221ms 1187ms 10.0s
recipe (name) 33ms 35ms 57ms 95ms 14ms 9.6s
recipe (description) 30ms 33ms 37ms 81ms 5ms 5.6s
recipe (instructions) 7ms 8ms 10ms 34ms 0ms 3.3s
synthetic (title) 71ms 73ms 83ms 336ms 1635ms 15.3s
synthetic (string inside object) 70ms 73ms 83ms 494ms 1741ms 10.2s

📚 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.

@@ -23,4 +23,7 @@ export interface User {
displayName?: string
imageUrl?: string
email?: string

/** global sanity user id */
sanityUserId?: string
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 field is already in the runtime object obtained from the API, its just absent from the type.
Happy to add other missing props too; there are more.

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.

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

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 :/

const isStaging = typeof __SANITY_STAGING__ !== 'undefined' && __SANITY_STAGING__ === true

function getCreateBaseUrl(customHost?: string) {
//@todo perhaps only support create staging through config
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.

TODO: think about how we want to handle this, if at all.
Is it bad to have a this sanity.build address here?

@@ -74,9 +74,11 @@ export function createUserStore({client: _client, currentUser}: UserStoreOptions

userLoader.prime('me', userFromCurrentUser)

if (userFromCurrentUser?.id) {
//@todo figure out if we are ok with this removal
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.

TODO/FIXME/COMMIT TO THIS:

Currently, useUser(userId) will return a User with sanityUserId (global user id), unless the userId happens to be the current user id. This is surprising.

The reason is this line of code, which puts currentUser into the user cache. currentUser obtained from /me does NOT have all propereties obtained using the /user/:id endpoint.

I make the case here that we should just delete this line, and allow the extra request, so that global token for current user can be obtained like so:

const currentUser = useCurrentUser()
const userId = currentUser?.id ?? ''
const [user] = useUser(userId)
// user.sanityUserId

If there are good reasons not to do this, I can restore this line, and add some bespoke /user/:currentUserId fetch in the integration codepath that needs it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Update: 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.

TLDR: all code related to user.id will be reverted/removed once Create has been updated with this new behavior.

@@ -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.

// The "Start in Create" action must be sorted first, so we need a sort key; the action string –
// we also don't want this string in the config interfaces, so we need the cheeky cast to smuggle it through
export const START_IN_CREATE_ACTION_NAME =
'startInCreate' as unknown as DocumentActionComponent['action']
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Suggestions on alternative ways to approach this is more than welcome!

@@ -0,0 +1,32 @@
import {defineEvent} from '@sanity/telemetry'
Copy link
Contributor

Choose a reason for hiding this comment

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

cc @sanity-io/data-eng for review

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The Create side equivalents will land in a couple of days fyi

@@ -0,0 +1,9 @@
import {useCurrentUser, useUser} from '../store'

export function useGlobalUserId(): string | undefined {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

All usage of this will be removed.

import {defineEvent} from '@sanity/telemetry'

export const StartInCreateClicked = defineEvent({
name: 'Start in Create clicked',
Copy link
Contributor

Choose a reason for hiding this comment

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

All event names should be title cased, see "Event naming" here: https://www.notion.so/sanityio/Product-Telemetry-71b5857ac3e7489b9355317c928c789a

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.

3 participants