diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c1e0d91e2b2..3d7e7b56ef8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -22,30 +22,5 @@ /packages/@sanity/cli/src/commands/typegen @sanity-io/content-lake-dx /packages/@sanity/cli/src/workers/typegenGenerate.ts @sanity-io/content-lake-dx -# Internals used by @sanity/presentation -# See https://github.com/sanity-io/visual-editing/blob/main/packages/presentation/src/internals.ts for exactly which exports -/packages/sanity/src/_singletons/structure/components/paneRouter/PaneRouterContext.tsx @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/comments/context/intent/CommentsIntentProvider.tsx @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/comments/types.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/config/document/fieldActions/define.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/config/document/fieldActions/types.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/field/paths/helpers.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/hooks/useEditState.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/preview/components/Preview.tsx @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/store/_legacy/datastores.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/store/_legacy/document/document-store.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/studio/activeWorkspaceMatcher/useActiveWorkspace.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/studio/workspace.tsx @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/util/draftUtils.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/util/isRecord.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/core/util/useUnique.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/router/utils/jsonParamsEncoding.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/structure/components/pane/PaneLayout.tsx @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/structure/components/paneRouter/types.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/structure/panes/document/DocumentPane.tsx @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/structure/panes/document/useDocumentPane.tsx @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/structure/panes/documentList/index.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/structure/panes/documentList/pane.ts @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/structure/panes/documentList/PaneContainer.tsx @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/structure/StructureToolProvider.tsx @sanity-io/ecosystem @sanity-io/studio -/packages/sanity/src/structure/types.ts @sanity-io/ecosystem @sanity-io/studio +# Presentation Tool, which interfaces with Visual Editing libraries +/packages/sanity/src/presentation/ @sanity-io/ecosystem diff --git a/.github/renovate.json b/.github/renovate.json index 0fdb966bf6d..918b03f11f2 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -46,13 +46,16 @@ "@portabletext/editor", "@sanity/bifur-client", "@sanity/client", + "@sanity/comlink", "@sanity/export", "@sanity/icons", "@sanity/insert-menu", "@sanity/mutate", - "@sanity/presentation", + "@sanity/presentation-comlink", + "@sanity/preview-url-secret", "@sanity/template-validator", "@sanity/ui", + "@sanity/visual-editing-csm", "get-it", "groq-js", "react-rx" @@ -67,16 +70,19 @@ "@portabletext/editor", "@sanity/bifur-client", "@sanity/client", + "@sanity/comlink", "@sanity/eslint-config-i18n", "@sanity/export", "@sanity/icons", "@sanity/insert-menu", "@sanity/mutate", "@sanity/pkg-utils", - "@sanity/presentation", + "@sanity/presentation-comlink", + "@sanity/preview-url-secret", "@sanity/template-validator", "@sanity/tsdoc", "@sanity/ui", + "@sanity/visual-editing-csm", "esbuild", "get-it", "groq-js", diff --git a/packages/sanity/.eslintrc.cjs b/packages/sanity/.eslintrc.cjs index 56eb8e13630..a9c02a461a8 100644 --- a/packages/sanity/.eslintrc.cjs +++ b/packages/sanity/.eslintrc.cjs @@ -182,6 +182,10 @@ module.exports = { name: 'sanity', message: 'Use relative type imports instead', }, + { + name: 'sanity/presentation', + message: 'Use relative type imports instead', + }, { name: 'sanity/structure', message: 'Use relative type imports instead', diff --git a/packages/sanity/package.config.ts b/packages/sanity/package.config.ts index bf55a949938..b07d12f9d0c 100644 --- a/packages/sanity/package.config.ts +++ b/packages/sanity/package.config.ts @@ -4,6 +4,12 @@ import {defineConfig} from '@sanity/pkg-utils' export default defineConfig({ ...baseConfig, + define: { + ...baseConfig.define, + PRESENTATION_ENABLE_LIVE_DRAFT_EVENTS: + process.env.PRESENTATION_ENABLE_LIVE_DRAFT_EVENTS === 'true', + }, + // Build unexposed bundles for scripts that need to be spawned/used in workers bundles: [ { diff --git a/packages/sanity/package.json b/packages/sanity/package.json index ed144b98996..6c1c0b1f5c7 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -162,6 +162,7 @@ "@sanity/cli": "3.70.0", "@sanity/client": "^6.24.4", "@sanity/color": "^3.0.0", + "@sanity/comlink": "^3.0.1", "@sanity/diff": "3.70.0", "@sanity/diff-match-patch": "^3.1.1", "@sanity/eventsource": "^5.0.0", @@ -170,16 +171,17 @@ "@sanity/image-url": "^1.0.2", "@sanity/import": "^3.37.9", "@sanity/insert-menu": "1.0.19", - "@sanity/logos": "^2.1.4", + "@sanity/logos": "^2.1.13", "@sanity/migrate": "3.70.0", "@sanity/mutator": "3.70.0", - "@sanity/presentation": "1.22.1", + "@sanity/presentation-comlink": "^1.0.0", + "@sanity/preview-url-secret": "^2.0.0", "@sanity/schema": "3.70.0", "@sanity/telemetry": "^0.7.7", "@sanity/types": "3.70.0", "@sanity/ui": "^2.11.2", "@sanity/util": "3.70.0", - "@sanity/uuid": "^3.0.1", + "@sanity/uuid": "^3.0.2", "@sentry/react": "^8.33.0", "@tanstack/react-table": "^8.16.0", "@tanstack/react-virtual": "^3.11.2", @@ -205,6 +207,7 @@ "esbuild-register": "^3.5.0", "execa": "^2.0.0", "exif-component": "^1.0.1", + "fast-deep-equal": "3.1.3", "form-data": "^4.0.0", "framer-motion": "^11.15.0", "get-it": "^8.6.6", @@ -223,6 +226,7 @@ "lodash": "^4.17.21", "log-symbols": "^2.2.0", "mendoza": "^3.0.0", + "mnemonist": "0.39.8", "module-alias": "^2.2.2", "nano-pubsub": "^3.0.0", "nanoid": "^3.1.30", @@ -231,6 +235,7 @@ "oneline": "^1.0.3", "open": "^8.4.0", "p-map": "^7.0.0", + "path-to-regexp": "^6.3.0", "pirates": "^4.0.0", "pluralize-esm": "^9.0.2", "polished": "^4.2.2", @@ -256,12 +261,15 @@ "semver": "^7.3.5", "shallow-equals": "^1.0.0", "speakingurl": "^14.0.1", + "suspend-react": "0.1.3", "tar-fs": "^2.1.1", "tar-stream": "^3.1.7", "use-device-pixel-ratio": "^1.1.0", "use-effect-event": "^1.0.2", "use-hot-module-reload": "^2.0.0", "use-sync-external-store": "^1.2.0", + "uuid": "^11.0.5", + "valibot": "0.31.1", "vite": "^6.0.7", "yargs": "^17.3.0" }, @@ -276,6 +284,7 @@ "@sanity/pkg-utils": "6.13.4", "@sanity/tsdoc": "1.0.169", "@sanity/ui-workshop": "^1.2.11", + "@sanity/visual-editing-csm": "^1.0.0", "@sentry/types": "^8.12.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.1.0", diff --git a/packages/sanity/src/_exports/presentation.ts b/packages/sanity/src/_exports/presentation.ts index 8874f624b93..d11c44e2ff0 100644 --- a/packages/sanity/src/_exports/presentation.ts +++ b/packages/sanity/src/_exports/presentation.ts @@ -1 +1 @@ -export * from '@sanity/presentation' +export * from '../presentation' diff --git a/packages/sanity/src/_singletons/context/PresentationContext.ts b/packages/sanity/src/_singletons/context/PresentationContext.ts new file mode 100644 index 00000000000..7abbd32a97e --- /dev/null +++ b/packages/sanity/src/_singletons/context/PresentationContext.ts @@ -0,0 +1,11 @@ +import {createContext} from 'sanity/_createContext' + +import type {PresentationContextValue} from '../../presentation/types' + +/** + * @internal + */ +export const PresentationContext = createContext( + 'sanity/_singletons/context/presentation', + null, +) diff --git a/packages/sanity/src/_singletons/context/PresentationDisplayedDocumentContext.ts b/packages/sanity/src/_singletons/context/PresentationDisplayedDocumentContext.ts new file mode 100644 index 00000000000..289570738cc --- /dev/null +++ b/packages/sanity/src/_singletons/context/PresentationDisplayedDocumentContext.ts @@ -0,0 +1,12 @@ +import {createContext} from 'sanity/_createContext' + +import type {PresentationDisplayedDocumentContextValue} from '../../presentation/loader/types' + +/** + * @internal + */ +export const PresentationDisplayedDocumentContext = + createContext( + 'sanity/_singletons/context/presentation/displayed-document', + null, + ) diff --git a/packages/sanity/src/_singletons/context/PresentationDocumentContext.ts b/packages/sanity/src/_singletons/context/PresentationDocumentContext.ts new file mode 100644 index 00000000000..8bcfafab8ce --- /dev/null +++ b/packages/sanity/src/_singletons/context/PresentationDocumentContext.ts @@ -0,0 +1,11 @@ +import {createContext} from 'sanity/_createContext' + +import type {PresentationDocumentContextValue} from '../../presentation/document/types' + +/** + * @internal + */ +export const PresentationDocumentContext = createContext( + 'sanity/_singletons/context/presentation/document', + null, +) diff --git a/packages/sanity/src/_singletons/context/PresentationNavigateContext.ts b/packages/sanity/src/_singletons/context/PresentationNavigateContext.ts new file mode 100644 index 00000000000..0a636bec977 --- /dev/null +++ b/packages/sanity/src/_singletons/context/PresentationNavigateContext.ts @@ -0,0 +1,11 @@ +import {createContext} from 'sanity/_createContext' + +import type {PresentationNavigateContextValue} from '../../presentation/types' + +/** + * @internal + */ +export const PresentationNavigateContext = createContext( + 'sanity/_singletons/context/presentation/navigate', + null, +) diff --git a/packages/sanity/src/_singletons/context/PresentationPanelsContext.ts b/packages/sanity/src/_singletons/context/PresentationPanelsContext.ts new file mode 100644 index 00000000000..897b360702f --- /dev/null +++ b/packages/sanity/src/_singletons/context/PresentationPanelsContext.ts @@ -0,0 +1,11 @@ +import {createContext} from 'sanity/_createContext' + +import type {PresentationPanelsContextValue} from '../../presentation/panels/types' + +/** + * @internal + */ +export const PresentationPanelsContext = createContext( + 'sanity/_singletons/context/presentation/panels', + null, +) diff --git a/packages/sanity/src/_singletons/context/PresentationParamsContext.ts b/packages/sanity/src/_singletons/context/PresentationParamsContext.ts new file mode 100644 index 00000000000..f63a8c46297 --- /dev/null +++ b/packages/sanity/src/_singletons/context/PresentationParamsContext.ts @@ -0,0 +1,11 @@ +import {createContext} from 'sanity/_createContext' + +import type {PresentationParamsContextValue} from '../../presentation/types' + +/** + * @internal + */ +export const PresentationParamsContext = createContext( + 'sanity/_singletons/context/presentation/params', + null, +) diff --git a/packages/sanity/src/_singletons/context/PresentationSharedStateContext.ts b/packages/sanity/src/_singletons/context/PresentationSharedStateContext.ts new file mode 100644 index 00000000000..f3a2ef5aff2 --- /dev/null +++ b/packages/sanity/src/_singletons/context/PresentationSharedStateContext.ts @@ -0,0 +1,12 @@ +import {createContext} from 'sanity/_createContext' + +import type {PresentationSharedStateContextValue} from '../../presentation/overlays/types' + +/** + * @internal + */ +export const PresentationSharedStateContext = + createContext( + 'sanity/_singletons/context/presentation/shared-state', + null, + ) diff --git a/packages/sanity/src/_singletons/index.ts b/packages/sanity/src/_singletons/index.ts index 0c63fb90f18..f4b0cdd2dcf 100644 --- a/packages/sanity/src/_singletons/index.ts +++ b/packages/sanity/src/_singletons/index.ts @@ -41,6 +41,13 @@ export * from './context/PortableTextMemberItemElementRefsContext' export * from './context/PortableTextMemberItemsContext' export * from './context/PresenceContext' export * from './context/PresenceTrackerContexts' +export * from './context/PresentationContext' +export * from './context/PresentationDisplayedDocumentContext' +export * from './context/PresentationDocumentContext' +export * from './context/PresentationNavigateContext' +export * from './context/PresentationPanelsContext' +export * from './context/PresentationParamsContext' +export * from './context/PresentationSharedStateContext' export * from './context/PreviewCardContext' export * from './context/ReferenceInputOptionsContext' export * from './context/ReferenceItemRefContext' diff --git a/packages/sanity/src/presentation/PostMessagePerspective.tsx b/packages/sanity/src/presentation/PostMessagePerspective.tsx new file mode 100644 index 00000000000..49ad5ef94d1 --- /dev/null +++ b/packages/sanity/src/presentation/PostMessagePerspective.tsx @@ -0,0 +1,29 @@ +import {type ClientPerspective} from '@sanity/client' +import {type FC, memo, useEffect} from 'react' + +import {type VisualEditingConnection} from './types' + +export interface PostMessagePerspectiveProps { + comlink: VisualEditingConnection + perspective: ClientPerspective +} + +const PostMessagePerspective: FC = (props) => { + const {comlink, perspective} = props + + // Return the perspective when requested + useEffect(() => { + return comlink.on('visual-editing/fetch-perspective', () => ({ + perspective, + })) + }, [comlink, perspective]) + + // Dispatch a perspective message when the perspective changes + useEffect(() => { + comlink.post('presentation/perspective', {perspective}) + }, [comlink, perspective]) + + return null +} + +export default memo(PostMessagePerspective) diff --git a/packages/sanity/src/presentation/PostMessageTelemetry.tsx b/packages/sanity/src/presentation/PostMessageTelemetry.tsx new file mode 100644 index 00000000000..4c26888b56f --- /dev/null +++ b/packages/sanity/src/presentation/PostMessageTelemetry.tsx @@ -0,0 +1,27 @@ +import {useTelemetry} from '@sanity/telemetry/react' +import {type FC, memo, useEffect} from 'react' + +import {type VisualEditingConnection} from './types' + +export interface PostMessageTelemetryProps { + comlink: VisualEditingConnection +} + +const PostMessageTelemetry: FC = (props) => { + const {comlink} = props + + const telemetry = useTelemetry() + + useEffect(() => { + return comlink.on('visual-editing/telemetry-log', async (message) => { + const {event, data} = message + + // SANITY_STUDIO_DEBUG_TELEMETRY ensures noop/in-browser logging for telemetry events + // eslint-disable-next-line no-unused-expressions + data ? telemetry.log(event, data) : telemetry.log(event) + }) + }, [comlink, telemetry]) + + return null +} +export default memo(PostMessageTelemetry) diff --git a/packages/sanity/src/presentation/PresentationContent.tsx b/packages/sanity/src/presentation/PresentationContent.tsx new file mode 100644 index 00000000000..8749b2b2810 --- /dev/null +++ b/packages/sanity/src/presentation/PresentationContent.tsx @@ -0,0 +1,91 @@ +import { + type Dispatch, + type FunctionComponent, + type PropsWithChildren, + type SetStateAction, +} from 'react' +import {type Path, type SanityDocument} from 'sanity' + +import {ContentEditor} from './editor/ContentEditor' +import {type CommentIntentGetter, CommentsIntentProvider} from './internals' +import {DisplayedDocumentBroadcasterProvider} from './loader/DisplayedDocumentBroadcaster' +import {Panel} from './panels/Panel' +import {PanelResizer} from './panels/PanelResizer' +import { + type MainDocumentState, + type PresentationParamsContextValue, + type PresentationSearchParams, + type StructureDocumentPaneParams, +} from './types' + +export interface PresentationContentProps { + documentId: PresentationParamsContextValue['id'] + documentsOnPage: {_id: string; _type: string}[] + documentType: PresentationParamsContextValue['type'] + getCommentIntent: CommentIntentGetter + mainDocumentState: MainDocumentState | undefined + onFocusPath: (path: Path) => void + onStructureParams: (params: StructureDocumentPaneParams) => void + searchParams: PresentationSearchParams + setDisplayedDocument: Dispatch | null | undefined>> + structureParams: StructureDocumentPaneParams +} + +const PresentationContentWrapper: FunctionComponent< + PropsWithChildren<{ + documentId?: string + getCommentIntent: CommentIntentGetter + setDisplayedDocument: Dispatch | null | undefined>> + }> +> = (props) => { + const {documentId, setDisplayedDocument, getCommentIntent} = props + return ( + <> + + + + + {props.children} + + + + + ) +} + +export const PresentationContent: FunctionComponent = (props) => { + const { + documentId, + documentsOnPage, + documentType, + getCommentIntent, + mainDocumentState, + onFocusPath, + onStructureParams, + searchParams, + setDisplayedDocument, + structureParams, + } = props + + return ( + + + + ) +} diff --git a/packages/sanity/src/presentation/PresentationNavigateProvider.tsx b/packages/sanity/src/presentation/PresentationNavigateProvider.tsx new file mode 100644 index 00000000000..2cea55b231e --- /dev/null +++ b/packages/sanity/src/presentation/PresentationNavigateProvider.tsx @@ -0,0 +1,25 @@ +import {type FunctionComponent, type PropsWithChildren, useCallback} from 'react' +import {PresentationNavigateContext} from 'sanity/_singletons' + +import {type PresentationNavigate, type PresentationNavigateContextValue} from './types' + +export const PresentationNavigateProvider: FunctionComponent< + PropsWithChildren<{ + navigate: PresentationNavigate + }> +> = function (props) { + const {children, navigate: _navigate} = props + + const navigate = useCallback( + (preview, document = undefined) => { + _navigate(document || {}, preview ? {preview} : {}) + }, + [_navigate], + ) + + return ( + + {children} + + ) +} diff --git a/packages/sanity/src/presentation/PresentationNavigator.tsx b/packages/sanity/src/presentation/PresentationNavigator.tsx new file mode 100644 index 00000000000..5f632226a44 --- /dev/null +++ b/packages/sanity/src/presentation/PresentationNavigator.tsx @@ -0,0 +1,61 @@ +import {memo, useCallback, useMemo} from 'react' + +import {Panel} from './panels/Panel' +import {PanelResizer} from './panels/PanelResizer' +import {type NavigatorOptions} from './types' +import {useLocalState} from './useLocalState' + +/** @internal */ +export interface UsePresentationNavigatorProps { + unstable_navigator?: NavigatorOptions +} + +/** @internal */ +export interface UsePresentationNavigatorState { + navigatorEnabled: boolean + toggleNavigator: (() => void) | undefined +} + +/** @internal */ +export function usePresentationNavigator( + props: UsePresentationNavigatorProps, +): [UsePresentationNavigatorState, () => React.JSX.Element] { + const {unstable_navigator} = props + + const navigatorProvided = !!unstable_navigator?.component + const [_navigatorEnabled, setNavigatorEnabled] = useLocalState( + 'presentation/navigator', + navigatorProvided, + ) + const navigatorEnabled = navigatorProvided ? _navigatorEnabled : false + const toggleNavigator = useMemo(() => { + if (!navigatorProvided) return undefined + + return () => setNavigatorEnabled((enabled) => !enabled) + }, [navigatorProvided, setNavigatorEnabled]) + + const Component = useCallback( + function PresentationNavigator() { + return <>{navigatorEnabled && } + }, + [navigatorEnabled, unstable_navigator], + ) + + return [{navigatorEnabled, toggleNavigator}, Component] +} + +function NavigatorComponent(props: NavigatorOptions) { + // eslint-disable-next-line @typescript-eslint/no-shadow + const {minWidth, maxWidth, component: NavigatorComponent} = props + // eslint-disable-next-line no-eq-null + const navigatorDisabled = minWidth != null && maxWidth != null && minWidth === maxWidth + return ( + <> + + + + + + ) +} +const Navigator = memo(NavigatorComponent) diff --git a/packages/sanity/src/presentation/PresentationParamsProvider.tsx b/packages/sanity/src/presentation/PresentationParamsProvider.tsx new file mode 100644 index 00000000000..bcb76415cc5 --- /dev/null +++ b/packages/sanity/src/presentation/PresentationParamsProvider.tsx @@ -0,0 +1,20 @@ +import {type FunctionComponent, type PropsWithChildren, useMemo} from 'react' +import {PresentationParamsContext} from 'sanity/_singletons' + +import {type PresentationParamsContextValue} from './types' + +export const PresentationParamsProvider: FunctionComponent< + PropsWithChildren<{ + params: PresentationParamsContextValue + }> +> = function (props) { + const {children, params} = props + + const context = useMemo(() => params, [params]) + + return ( + + {children} + + ) +} diff --git a/packages/sanity/src/presentation/PresentationProvider.tsx b/packages/sanity/src/presentation/PresentationProvider.tsx new file mode 100644 index 00000000000..7f4676a9d11 --- /dev/null +++ b/packages/sanity/src/presentation/PresentationProvider.tsx @@ -0,0 +1,37 @@ +import {type FunctionComponent, type PropsWithChildren, useMemo} from 'react' +import {PresentationContext} from 'sanity/_singletons' + +import { + type PresentationContextValue, + type PresentationNavigate, + type PresentationParamsContextValue, + type PresentationSearchParams, + type StructureDocumentPaneParams, +} from './types' + +export const PresentationProvider: FunctionComponent< + PropsWithChildren<{ + devMode: boolean + name: string + navigate: PresentationNavigate + params: PresentationParamsContextValue + searchParams: PresentationSearchParams + structureParams: StructureDocumentPaneParams + }> +> = function (props) { + const {children, devMode, name, navigate, params, searchParams, structureParams} = props + + const context = useMemo( + () => ({ + devMode, + name, + navigate, + params, + searchParams, + structureParams, + }), + [devMode, name, navigate, params, searchParams, structureParams], + ) + + return {children} +} diff --git a/packages/sanity/src/presentation/PresentationSpinner.tsx b/packages/sanity/src/presentation/PresentationSpinner.tsx new file mode 100644 index 00000000000..f319c7bcbb0 --- /dev/null +++ b/packages/sanity/src/presentation/PresentationSpinner.tsx @@ -0,0 +1,9 @@ +import {Flex, Spinner} from '@sanity/ui' + +export function PresentationSpinner(): React.JSX.Element { + return ( + + + + ) +} diff --git a/packages/sanity/src/presentation/PresentationTool.tsx b/packages/sanity/src/presentation/PresentationTool.tsx new file mode 100644 index 00000000000..9d36149f2af --- /dev/null +++ b/packages/sanity/src/presentation/PresentationTool.tsx @@ -0,0 +1,660 @@ +/* eslint-disable max-statements,@typescript-eslint/no-shadow */ +import {studioPath} from '@sanity/client/csm' +import { + type Controller, + createConnectionMachine, + createController, + type Message, +} from '@sanity/comlink' +import { + createCompatibilityActors, + type PreviewKitNodeMsg, + type VisualEditingControllerMsg, + type VisualEditingNodeMsg, +} from '@sanity/presentation-comlink' +import { + urlSearchParamVercelProtectionBypass, + urlSearchParamVercelSetBypassCookie, +} from '@sanity/preview-url-secret/constants' +import {BoundaryElementProvider, Flex} from '@sanity/ui' +import {lazy, Suspense, useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react' +import { + COMMENTS_INSPECTOR_NAME, + type Path, + type SanityDocument, + type Tool, + useDataset, + useProjectId, +} from 'sanity' +import {type RouterContextValue, useRouter} from 'sanity/router' +import {styled} from 'styled-components' +import {useEffectEvent} from 'use-effect-event' + +import {DEFAULT_TOOL_NAME, EDIT_INTENT_MODE, LIVE_DRAFT_EVENTS_ENABLED} from './constants' +import PostMessageFeatures from './features/PostMessageFeatures' +import {type CommentIntentGetter, useUnique, useWorkspace} from './internals' +import {debounce} from './lib/debounce' +import {SharedStateProvider} from './overlays/SharedStateProvider' +import {Panel} from './panels/Panel' +import {Panels} from './panels/Panels' +import {PresentationContent} from './PresentationContent' +import {PresentationNavigateProvider} from './PresentationNavigateProvider' +import {usePresentationNavigator} from './PresentationNavigator' +import {PresentationParamsProvider} from './PresentationParamsProvider' +import {PresentationProvider} from './PresentationProvider' +import {Preview} from './preview/Preview' +import { + ACTION_IFRAME_LOADED, + ACTION_IFRAME_REFRESH, + ACTION_VISUAL_EDITING_OVERLAYS_TOGGLE, + presentationReducer, + presentationReducerInit, +} from './reducers/presentationReducer' +import {RevisionSwitcher} from './RevisionSwitcher' +import { + type FrameState, + type PresentationNavigate, + type PresentationPerspective, + type PresentationPluginOptions, + type PresentationStateParams, + type PresentationViewport, + type StructureDocumentPaneParams, + type VisualEditingConnection, +} from './types' +import {useDocumentsOnPage} from './useDocumentsOnPage' +import {useMainDocument} from './useMainDocument' +import {useParams} from './useParams' +import {usePopups} from './usePopups' +import {usePreviewUrl} from './usePreviewUrl' +import {useStatus} from './useStatus' + +const LoaderQueries = lazy(() => import('./loader/LoaderQueries')) +const LiveQueries = lazy(() => import('./loader/LiveQueries')) +const PostMessageDocuments = lazy(() => import('./overlays/PostMessageDocuments')) +const PostMessageRefreshMutations = lazy(() => import('./editor/PostMessageRefreshMutations')) +const PostMessagePerspective = lazy(() => import('./PostMessagePerspective')) +const PostMessagePreviewSnapshots = lazy(() => import('./editor/PostMessagePreviewSnapshots')) +const PostMessageSchema = lazy(() => import('./overlays/schema/PostMessageSchema')) +const PostMessageTelemetry = lazy(() => import('./PostMessageTelemetry')) + +const Container = styled(Flex)` + overflow-x: auto; +` + +export default function PresentationTool(props: { + tool: Tool + canCreateUrlPreviewSecrets: boolean + canToggleSharePreviewAccess: boolean + canUseSharedPreviewAccess: boolean + vercelProtectionBypass: string | null +}): React.JSX.Element { + const { + canCreateUrlPreviewSecrets, + canToggleSharePreviewAccess, + canUseSharedPreviewAccess, + tool, + vercelProtectionBypass, + } = props + const components = tool.options?.components + const _previewUrl = tool.options?.previewUrl + const name = tool.name || DEFAULT_TOOL_NAME + const {unstable_navigator, unstable_header} = components || {} + + const {navigate: routerNavigate, state: routerState} = useRouter() as RouterContextValue & { + state: PresentationStateParams + } + const routerSearchParams = useUnique(Object.fromEntries(routerState._searchParams || [])) + + const initialPreviewUrl = usePreviewUrl( + _previewUrl || '/', + name, + routerSearchParams.perspective === 'published' ? 'published' : 'previewDrafts', + routerSearchParams.preview || null, + canCreateUrlPreviewSecrets, + ) + const canSharePreviewAccess = useMemo(() => { + if ( + _previewUrl && + typeof _previewUrl === 'object' && + 'draftMode' in _previewUrl && + _previewUrl.draftMode + ) { + // eslint-disable-next-line no-console + console.warn('previewUrl.draftMode is deprecated, use previewUrl.previewMode instead') + return _previewUrl.draftMode.shareAccess !== false + } + if ( + _previewUrl && + typeof _previewUrl === 'object' && + 'previewMode' in _previewUrl && + _previewUrl.previewMode + ) { + return _previewUrl.previewMode.shareAccess !== false + } + return false + }, [_previewUrl]) + + const [devMode] = useState(() => { + const option = tool.options?.devMode + + if (typeof option === 'function') return option() + if (typeof option === 'boolean') return option + + return typeof window !== 'undefined' && window.location.hostname === 'localhost' + }) + + const targetOrigin = useMemo(() => { + return initialPreviewUrl.origin + }, [initialPreviewUrl.origin]) + + const iframeRef = useRef(null) + + const [controller, setController] = useState() + const [visualEditingComlink, setVisualEditingComlink] = useState( + null, + ) + + const frameStateRef = useRef({ + title: undefined, + url: undefined, + }) + + const { + navigate: _navigate, + navigationHistory, + params, + searchParams, + structureParams, + } = useParams({ + initialPreviewUrl, + routerNavigate, + routerState, + routerSearchParams, + frameStateRef, + }) + + // Most navigation events should be debounced, so use this unless explicitly needed + const navigate = useMemo(() => debounce(_navigate, 50), [_navigate]) + + const [state, dispatch] = useReducer(presentationReducer, {}, presentationReducerInit) + + const perspective = useMemo( + () => (params.perspective ? 'published' : 'previewDrafts'), + [params.perspective], + ) + + const viewport = useMemo(() => (params.viewport ? 'mobile' : 'desktop'), [params.viewport]) + + const [documentsOnPage, setDocumentsOnPage] = useDocumentsOnPage(perspective, frameStateRef) + + const projectId = useProjectId() + const dataset = useDataset() + + const mainDocumentState = useMainDocument({ + // Prevent flash of content by using immediate navigation + navigate: _navigate, + navigationHistory, + path: params.preview, + previewUrl: tool.options?.previewUrl, + resolvers: tool.options?.resolve?.mainDocuments, + }) + + const [overlaysConnection, setOverlaysConnection] = useStatus() + const [loadersConnection, setLoadersConnection] = useStatus() + const [previewKitConnection, setPreviewKitConnection] = useStatus() + + const {open: handleOpenPopup} = usePopups(controller) + + const isLoading = state.iframe.status === 'loading' + + useEffect(() => { + const target = iframeRef.current?.contentWindow + + if (!target || isLoading) return undefined + + const controller = createController({targetOrigin}) + controller.addTarget(target) + setController(controller) + + return () => { + controller.destroy() + setController(undefined) + } + }, [targetOrigin, isLoading]) + + const handleNavigate = useEffectEvent( + (nextState, nextSearchState, forceReplace) => + navigate(nextState, nextSearchState, forceReplace), + ) + useEffect(() => { + if (!controller) return undefined + + const comlink = controller.createChannel( + { + name: 'presentation', + heartbeat: true, + connectTo: 'visual-editing', + }, + createConnectionMachine().provide({ + actors: createCompatibilityActors(), + }), + ) + + comlink.on('visual-editing/focus', (data) => { + if (!('id' in data)) return + handleNavigate({ + type: data.type, + id: data.id, + path: data.path, + }) + }) + + comlink.on('visual-editing/navigate', (data) => { + const {title, url} = data + if (frameStateRef.current.url !== url) { + try { + // Handle bypass params being forwarded to the final URL + const [urlWithoutSearch, search] = url.split('?') + const searchParams = new URLSearchParams(search) + searchParams.delete(urlSearchParamVercelProtectionBypass) + searchParams.delete(urlSearchParamVercelSetBypassCookie) + handleNavigate( + {}, + {preview: `${urlWithoutSearch}${searchParams.size > 0 ? '?' : ''}${searchParams}`}, + ) + } catch { + handleNavigate({}, {preview: url}) + } + } + frameStateRef.current = {title, url} + }) + + comlink.on('visual-editing/meta', (data) => { + frameStateRef.current.title = data.title + }) + + comlink.on('visual-editing/toggle', (data) => { + dispatch({ + type: ACTION_VISUAL_EDITING_OVERLAYS_TOGGLE, + enabled: data.enabled, + }) + }) + + comlink.on('visual-editing/documents', (data) => { + setDocumentsOnPage( + 'visual-editing', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data.perspective as unknown as any, + data.documents, + ) + }) + + // @todo This won't work for multiple window contexts? + comlink.on('visual-editing/refreshing', (data) => { + if (data.source === 'manual') { + clearTimeout(refreshRef.current) + } else if (data.source === 'mutation') { + dispatch({type: ACTION_IFRAME_REFRESH}) + } + }) + + comlink.on('visual-editing/refreshed', () => { + dispatch({type: ACTION_IFRAME_LOADED}) + }) + + comlink.onStatus(setOverlaysConnection) + + const stop = comlink.start() + setVisualEditingComlink(comlink) + + return () => { + stop() + setVisualEditingComlink(null) + } + }, [controller, handleNavigate, setDocumentsOnPage, setOverlaysConnection, targetOrigin]) + + useEffect(() => { + if (!controller) return undefined + const comlink = controller.createChannel( + { + name: 'presentation', + connectTo: 'preview-kit', + heartbeat: true, + }, + createConnectionMachine().provide({ + actors: createCompatibilityActors(), + }), + ) + + comlink.onStatus(setPreviewKitConnection) + + comlink.on('preview-kit/documents', (data) => { + if (data.projectId === projectId && data.dataset === dataset) { + setDocumentsOnPage( + 'preview-kit', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data.perspective as unknown as any, + data.documents, + ) + } + }) + + return comlink.start() + }, [controller, dataset, projectId, setDocumentsOnPage, setPreviewKitConnection, targetOrigin]) + + const handleFocusPath = useCallback( + (nextPath: Path) => { + // Don’t need to explicitly set the id here because it was either already set via postMessage or is the same if navigating in the document pane + navigate({path: studioPath.toString(nextPath)}, {}, true) + }, + [navigate], + ) + + const handlePreviewPath = useCallback( + (nextPath: string) => { + const url = new URL(nextPath, initialPreviewUrl.origin) + const preview = url.pathname + url.search + if (url.origin === initialPreviewUrl.origin && preview !== params.preview) { + navigate({}, {preview}) + } + }, + [initialPreviewUrl, params, navigate], + ) + + const handleStructureParams = useCallback( + (structureParams: StructureDocumentPaneParams) => { + navigate({}, structureParams) + }, + [navigate], + ) + + // Dispatch a focus or blur message when the id or path change + useEffect(() => { + if (params.id && params.path) { + visualEditingComlink?.post('presentation/focus', {id: params.id, path: params.path}) + } else { + visualEditingComlink?.post('presentation/blur') + } + }, [params.id, params.path, visualEditingComlink]) + + // Dispatch a navigation message when the preview param changes + useEffect(() => { + if ( + frameStateRef.current.url && + params.preview && + frameStateRef.current.url !== params.preview + ) { + frameStateRef.current.url = params.preview + if (overlaysConnection !== 'connected' && iframeRef.current) { + iframeRef.current.src = `${targetOrigin}${params.preview}` + } else { + visualEditingComlink?.post('presentation/navigate', { + url: params.preview, + type: 'replace', + }) + } + } + }, [overlaysConnection, targetOrigin, params.preview, visualEditingComlink]) + + const toggleOverlay = useCallback( + () => visualEditingComlink?.post('presentation/toggle-overlay'), + [visualEditingComlink], + ) + + const [displayedDocument, setDisplayedDocument] = useState< + Partial | null | undefined + >(null) + + useEffect(() => { + const handleKeyUp = (e: KeyboardEvent) => { + if (isAltKey(e)) { + toggleOverlay() + } + } + const handleKeydown = (e: KeyboardEvent) => { + if (isAltKey(e)) { + toggleOverlay() + } + + if (isHotkey(['mod', '\\'], e)) { + toggleOverlay() + } + } + window.addEventListener('keydown', handleKeydown) + window.addEventListener('keyup', handleKeyUp) + return () => { + window.removeEventListener('keydown', handleKeydown) + window.removeEventListener('keyup', handleKeyUp) + } + }, [toggleOverlay]) + + const [boundaryElement, setBoundaryElement] = useState(null) + + const [{navigatorEnabled, toggleNavigator}, PresentationNavigator] = usePresentationNavigator({ + unstable_navigator, + }) + + // Handle edge case where the `&rev=` parameter gets "stuck" + const idRef = useRef(params.id) + useEffect(() => { + if (params.rev && idRef.current && params.id !== idRef.current) { + navigate({}, {rev: undefined}) + } + idRef.current = params.id + }) + + const refreshRef = useRef(undefined) + const handleRefresh = useCallback( + (fallback: () => void) => { + dispatch({type: ACTION_IFRAME_REFRESH}) + if (visualEditingComlink) { + // We only wait 300ms for the iframe to ack the refresh request before running the fallback logic + refreshRef.current = window.setTimeout(fallback, 300) + visualEditingComlink.post('presentation/refresh', { + source: 'manual', + livePreviewEnabled: + previewKitConnection === 'connected' || loadersConnection === 'connected', + }) + return + } + fallback() + }, + [loadersConnection, previewKitConnection, visualEditingComlink], + ) + + const workspace = useWorkspace() + + const getCommentIntent = useCallback( + ({id, type, path}) => { + if (frameStateRef.current.url) { + return { + title: frameStateRef.current.title || frameStateRef.current.url, + name: 'edit', + params: { + id, + path, + type, + inspect: COMMENTS_INSPECTOR_NAME, + workspace: workspace.name, + mode: EDIT_INTENT_MODE, + preview: params.preview, + }, + } + } + return undefined + }, + [params.preview, workspace.name], + ) + + const setViewport = useCallback( + (next: PresentationViewport) => { + // Omit the viewport URL search param if the next viewport state is the + // default: 'desktop' + const viewport = next === 'desktop' ? undefined : 'mobile' + navigate({}, {viewport}, true) + }, + [navigate], + ) + + const setPerspective = useCallback( + (next: PresentationPerspective) => { + // Omit the perspective URL search param if the next perspective state is + // the default: 'previewDrafts' + const perspective = next === 'previewDrafts' ? undefined : next + navigate({}, {perspective}) + }, + [navigate], + ) + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + {controller && + (LIVE_DRAFT_EVENTS_ENABLED ? ( + + ) : ( + + ))} + {visualEditingComlink && params.id && params.type && ( + + )} + {visualEditingComlink && ( + + )} + {visualEditingComlink && documentsOnPage.length > 0 && ( + + )} + {visualEditingComlink && } + {visualEditingComlink && } + {visualEditingComlink && ( + + )} + {visualEditingComlink && } + {params.id && params.type && ( + + )} + + + ) +} + +function isAltKey(event: KeyboardEvent): boolean { + return event.key === 'Alt' +} + +const IS_MAC = + typeof window != 'undefined' && /Mac|iPod|iPhone|iPad/.test(window.navigator.platform) +const MODIFIERS: Record = { + alt: 'altKey', + ctrl: 'ctrlKey', + mod: IS_MAC ? 'metaKey' : 'ctrlKey', + shift: 'shiftKey', +} +function isHotkey(keys: string[], event: KeyboardEvent): boolean { + return keys.every((key) => { + if (MODIFIERS[key]) { + return event[MODIFIERS[key]] + } + return event.key === key.toUpperCase() + }) +} diff --git a/packages/sanity/src/presentation/PresentationToolGrantsCheck.tsx b/packages/sanity/src/presentation/PresentationToolGrantsCheck.tsx new file mode 100644 index 00000000000..7567dd31b5a --- /dev/null +++ b/packages/sanity/src/presentation/PresentationToolGrantsCheck.tsx @@ -0,0 +1,103 @@ +import { + schemaIdSingleton, + schemaType, + schemaTypeSingleton, +} from '@sanity/preview-url-secret/constants' +import {useToast} from '@sanity/ui' +import {uuid} from '@sanity/uuid' +import {useEffect, useState} from 'react' +import {type PermissionCheckResult, type Tool, useGrantsStore, useTranslation} from 'sanity' + +import {presentationLocaleNamespace} from './i18n' +import {PresentationSpinner} from './PresentationSpinner' +import PresentationTool from './PresentationTool' +import {type PresentationPluginOptions} from './types' +import {useVercelBypassSecret} from './useVercelBypassSecret' + +export default function PresentationToolGrantsCheck(props: { + tool: Tool +}): React.JSX.Element { + const {t} = useTranslation(presentationLocaleNamespace) + const {previewUrl} = props.tool.options ?? {} + const {push: pushToast} = useToast() + const willGeneratePreviewUrlSecret = + typeof previewUrl === 'object' || typeof previewUrl === 'function' + const grantsStore = useGrantsStore() + const [previewAccessSharingCreatePermission, setCreateAccessSharingPermission] = + useState(null) + const [previewAccessSharingUpdatePermission, setUpdateAccessSharingPermission] = + useState(null) + const [previewAccessSharingReadPermission, setReadAccessSharingPermission] = + useState(null) + const [previewUrlSecretPermission, setPreviewUrlSecretPermission] = + useState(null) + + useEffect(() => { + if (!willGeneratePreviewUrlSecret) return undefined + + const previewCreateAccessSharingPermissionSubscription = grantsStore + .checkDocumentPermission('create', {_id: schemaIdSingleton, _type: schemaTypeSingleton}) + .subscribe(setCreateAccessSharingPermission) + const previewUpdateAccessSharingPermissionSubscription = grantsStore + .checkDocumentPermission('update', {_id: schemaIdSingleton, _type: schemaTypeSingleton}) + .subscribe(setUpdateAccessSharingPermission) + const previewReadAccessSharingPermissionSubscription = grantsStore + .checkDocumentPermission('read', {_id: schemaIdSingleton, _type: schemaTypeSingleton}) + .subscribe(setReadAccessSharingPermission) + const previewUrlSecretPermissionSubscription = grantsStore + .checkDocumentPermission('create', {_id: `drafts.${uuid()}`, _type: schemaType}) + .subscribe(setPreviewUrlSecretPermission) + + return () => { + previewCreateAccessSharingPermissionSubscription.unsubscribe() + previewUpdateAccessSharingPermissionSubscription.unsubscribe() + previewReadAccessSharingPermissionSubscription.unsubscribe() + previewUrlSecretPermissionSubscription.unsubscribe() + } + }, [grantsStore, willGeneratePreviewUrlSecret]) + + const canCreateUrlPreviewSecrets = previewUrlSecretPermission?.granted + + useEffect(() => { + if (!willGeneratePreviewUrlSecret || canCreateUrlPreviewSecrets !== false) return undefined + const raf = requestAnimationFrame(() => + pushToast({ + closable: true, + status: 'error', + duration: 30_000, + title: t('preview-url-secret.missing-grants'), + }), + ) + return () => cancelAnimationFrame(raf) + }, [canCreateUrlPreviewSecrets, pushToast, t, willGeneratePreviewUrlSecret]) + + const [vercelProtectionBypass, vercelProtectionBypassReadyState] = useVercelBypassSecret() + + if ( + vercelProtectionBypassReadyState === 'loading' || + (willGeneratePreviewUrlSecret && + (!previewAccessSharingCreatePermission || + typeof previewAccessSharingCreatePermission.granted === 'undefined' || + !previewAccessSharingUpdatePermission || + typeof previewAccessSharingUpdatePermission.granted === 'undefined' || + !previewUrlSecretPermission || + !previewAccessSharingReadPermission || + typeof previewAccessSharingReadPermission.granted === 'undefined' || + typeof previewUrlSecretPermission.granted === 'undefined')) + ) { + return + } + + return ( + + ) +} diff --git a/packages/sanity/src/presentation/RevisionSwitcher.tsx b/packages/sanity/src/presentation/RevisionSwitcher.tsx new file mode 100644 index 00000000000..db4bc70fe88 --- /dev/null +++ b/packages/sanity/src/presentation/RevisionSwitcher.tsx @@ -0,0 +1,40 @@ +import {type FunctionComponent} from 'react' + +import {useEditState} from './internals' +import {type PresentationNavigate} from './types' +import {useEffectOnChange} from './util/useEffectOnChange' + +interface RevisionSwitcherProps { + documentId: string + documentRevision: string | undefined + documentType: string + navigate: PresentationNavigate + perspective: 'previewDrafts' | 'published' +} + +/** + * Renderless component to handle displaying the correct revision when the + * perspective is switched. When the perspective changes to 'published', the + * `rev` parameter correpsonding to the published document is resolved from the + * published edit state. When the perspective changes to 'previewDrafts', the + * `rev` parameter is removed, as the latest draft should be displayed. + * @internal + */ +export const RevisionSwitcher: FunctionComponent = function (props) { + const {documentId, documentType, navigate, perspective, documentRevision} = props + + const editState = useEditState(documentId, documentType) + + useEffectOnChange(perspective, (value) => { + let rev: string | undefined = undefined + if (value === 'published' && editState.published) { + const {_updatedAt, _rev} = editState.published + rev = `${_updatedAt}/${_rev}` + } + if (documentRevision !== rev) { + navigate({}, {rev}, true) + } + }) + + return null +} diff --git a/packages/sanity/src/presentation/__tests__/usePreviewUrl.test.tsx b/packages/sanity/src/presentation/__tests__/usePreviewUrl.test.tsx new file mode 100644 index 00000000000..8ab6ff02c88 --- /dev/null +++ b/packages/sanity/src/presentation/__tests__/usePreviewUrl.test.tsx @@ -0,0 +1,185 @@ +import { + urlSearchParamVercelProtectionBypass, + urlSearchParamVercelSetBypassCookie, +} from '@sanity/preview-url-secret/constants' +import {definePreviewUrl} from '@sanity/preview-url-secret/define-preview-url' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import {renderToStaticMarkup} from 'react-dom/server' +import {type SanityClient} from 'sanity' +import {suspend} from 'suspend-react' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +import {type PreviewUrlOption} from '../types' +import {usePreviewUrl} from '../usePreviewUrl' + +vi.mock('sanity', async () => { + const sanity = await vi.importActual('sanity') + return { + ...sanity, + useActiveWorkspace: () => null, + useClient: () => null, + useCurrentUser: () => null, + } +}) +vi.mock('sanity/router') +vi.mock('sanity/structure') +vi.mock('suspend-react') + +beforeEach(() => { + vi.resetAllMocks() +}) + +function TestPrinter(props: {previewUrl: PreviewUrlOption; previewSearchParam?: string | null}) { + return `${usePreviewUrl(props.previewUrl, 'presentation', 'previewDrafts', props.previewSearchParam || null, true)}` +} + +describe('previewUrl handling', () => { + test.skip('/preview', async () => { + expect(renderToStaticMarkup()).toMatchInlineSnapshot( + `"http://localhost:3000/preview"`, + ) + }) + + test.skip('/', async () => { + expect(renderToStaticMarkup()).toMatchInlineSnapshot( + `"http://localhost:3000/"`, + ) + }) + + test('Preview Mode on same origin', async () => { + const previewUrl = { + previewMode: {enable: '/api/draft'}, + } satisfies PreviewUrlOption + const resolvePreviewUrl = definePreviewUrl(previewUrl) + let resolvedPreviewUrl = await resolvePreviewUrl({ + client: null as unknown as SanityClient, + previewUrlSecret: 'abc123', + previewSearchParam: null, + studioPreviewPerspective: 'previewDraft', + }) + vi.mocked(suspend).mockReturnValue(resolvedPreviewUrl) + expect(renderToStaticMarkup()).toMatchInlineSnapshot( + `"http://localhost:3000/api/draft?sanity-preview-secret=abc123&sanity-preview-perspective=previewDraft&sanity-preview-pathname=%2F"`, + ) + resolvedPreviewUrl = await resolvePreviewUrl({ + client: null as unknown as SanityClient, + previewUrlSecret: 'dfg456', + previewSearchParam: '/preview', + studioPreviewPerspective: 'previewDraft', + }) + vi.mocked(suspend).mockReturnValue(resolvedPreviewUrl) + expect(renderToStaticMarkup()).toMatchInlineSnapshot( + `"http://localhost:3000/api/draft?sanity-preview-secret=dfg456&sanity-preview-perspective=previewDraft&sanity-preview-pathname=%2Fpreview"`, + ) + }) + + test('Preview Mode on same origin with redirect', async () => { + const previewUrl = { + preview: '/preview', + previewMode: {enable: '/api/draft'}, + } satisfies PreviewUrlOption + const resolvePreviewUrl = definePreviewUrl(previewUrl) + let resolvedPreviewUrl = await resolvePreviewUrl({ + client: null as unknown as SanityClient, + previewUrlSecret: 'abc123', + previewSearchParam: null, + studioPreviewPerspective: 'previewDraft', + }) + vi.mocked(suspend).mockReturnValue(resolvedPreviewUrl) + expect(renderToStaticMarkup()).toMatchInlineSnapshot( + `"http://localhost:3000/api/draft?sanity-preview-secret=abc123&sanity-preview-perspective=previewDraft&sanity-preview-pathname=%2Fpreview"`, + ) + resolvedPreviewUrl = await resolvePreviewUrl({ + client: null as unknown as SanityClient, + previewUrlSecret: 'dfg456', + previewSearchParam: '/preview', + studioPreviewPerspective: 'previewDraft', + }) + vi.mocked(suspend).mockReturnValue(resolvedPreviewUrl) + expect(renderToStaticMarkup()).toMatchInlineSnapshot( + `"http://localhost:3000/api/draft?sanity-preview-secret=dfg456&sanity-preview-perspective=previewDraft&sanity-preview-pathname=%2Fpreview"`, + ) + }) + + test('Preview Mode on cross origin', async () => { + const previewUrl = { + origin: 'https://my.vercel.app', + previewMode: {enable: '/api/draft'}, + } satisfies PreviewUrlOption + const resolvePreviewUrl = definePreviewUrl(previewUrl) + let resolvedPreviewUrl = await resolvePreviewUrl({ + client: null as unknown as SanityClient, + previewUrlSecret: 'abc123', + previewSearchParam: null, + studioPreviewPerspective: 'previewDraft', + }) + vi.mocked(suspend).mockReturnValue(resolvedPreviewUrl) + expect(renderToStaticMarkup()).toMatchInlineSnapshot( + `"https://my.vercel.app/api/draft?sanity-preview-secret=abc123&sanity-preview-perspective=previewDraft&sanity-preview-pathname=%2F"`, + ) + resolvedPreviewUrl = await resolvePreviewUrl({ + client: null as unknown as SanityClient, + previewUrlSecret: 'dfg456', + previewSearchParam: '/preview', + studioPreviewPerspective: 'previewDraft', + }) + vi.mocked(suspend).mockReturnValue(resolvedPreviewUrl) + expect(renderToStaticMarkup()).toMatchInlineSnapshot( + `"https://my.vercel.app/api/draft?sanity-preview-secret=dfg456&sanity-preview-perspective=previewDraft&sanity-preview-pathname=%2Fpreview"`, + ) + }) + + test('Preview Mode on cross origin with redirect', async () => { + const previewUrl = { + origin: 'https://my.vercel.app', + preview: '/preview', + previewMode: { + enable: `/api/draft-mode/enable?${new URLSearchParams({ + [urlSearchParamVercelProtectionBypass]: 'abc123', + // samesitenone is required since the request is from an iframe + [urlSearchParamVercelSetBypassCookie]: 'samesitenone', + })}`, + }, + } satisfies PreviewUrlOption + const resolvePreviewUrl = definePreviewUrl(previewUrl) + let resolvedPreviewUrl = await resolvePreviewUrl({ + client: null as unknown as SanityClient, + previewUrlSecret: 'abc123', + previewSearchParam: null, + studioPreviewPerspective: 'previewDraft', + }) + vi.mocked(suspend).mockReturnValue(resolvedPreviewUrl) + expect(renderToStaticMarkup()).toMatchInlineSnapshot( + `"https://my.vercel.app/api/draft-mode/enable?x-vercel-protection-bypass=abc123&x-vercel-set-bypass-cookie=samesitenone&sanity-preview-secret=abc123&sanity-preview-perspective=previewDraft&sanity-preview-pathname=%2Fpreview"`, + ) + resolvedPreviewUrl = await resolvePreviewUrl({ + client: null as unknown as SanityClient, + previewUrlSecret: 'dfg456', + previewSearchParam: '/preview', + studioPreviewPerspective: 'previewDraft', + }) + vi.mocked(suspend).mockReturnValue(resolvedPreviewUrl) + expect(renderToStaticMarkup()).toMatchInlineSnapshot( + `"https://my.vercel.app/api/draft-mode/enable?x-vercel-protection-bypass=abc123&x-vercel-set-bypass-cookie=samesitenone&sanity-preview-secret=dfg456&sanity-preview-perspective=previewDraft&sanity-preview-pathname=%2Fpreview"`, + ) + }) + + test.skip('Invalid URL', () => { + expect(() => + renderToStaticMarkup(), + ).toThrowErrorMatchingInlineSnapshot(`[TypeError: Invalid URL]`) + expect(() => + renderToStaticMarkup(), + ).toThrowErrorMatchingInlineSnapshot(`[TypeError: Invalid URL]`) + expect(() => + renderToStaticMarkup( + , + ), + ).toThrowErrorMatchingInlineSnapshot(`[TypeError: Invalid URL]`) + expect(() => + renderToStaticMarkup( + , + ), + ).toThrowErrorMatchingInlineSnapshot(`[TypeError: Invalid URL]`) + }) +}) diff --git a/packages/sanity/src/presentation/components/ErrorCard.tsx b/packages/sanity/src/presentation/components/ErrorCard.tsx new file mode 100644 index 00000000000..9634536a394 --- /dev/null +++ b/packages/sanity/src/presentation/components/ErrorCard.tsx @@ -0,0 +1,64 @@ +/* eslint-disable no-nested-ternary */ +import {Box, Card, type CardProps, Container, Flex, Inline, Stack, Text} from '@sanity/ui' +import {type ReactNode} from 'react' +import {useTranslation} from 'sanity' + +import {Button} from '../../ui-components' +import {presentationLocaleNamespace} from '../i18n' + +export function ErrorCard( + props: { + children?: ReactNode + message: string + onRetry?: () => void + onContinueAnyway?: () => void + } & CardProps, +): React.JSX.Element { + const {children, message, onRetry, onContinueAnyway, ...restProps} = props + + const {t} = useTranslation(presentationLocaleNamespace) + + const retryButton = ( +