Skip to content

feat(react): add PortableTextEditorProvider #385

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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/kitchensink-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"prettier": "@sanity/prettier-config",
"dependencies": {
"@paramour/css": "1.0.0-rc.2",
"@portabletext/editor": "^1.44.13",
"@sanity/icons": "^3.7.0",
"@sanity/sdk": "workspace:*",
"@sanity/sdk-react": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"rxjs": "^7.8.1"
},
"devDependencies": {
"@portabletext/editor": "^1.44.13",
"@repo/config-eslint": "workspace:*",
"@repo/config-test": "workspace:*",
"@repo/package.config": "workspace:*",
Expand All @@ -91,6 +92,7 @@
"vitest": "^3.1.1"
},
"peerDependencies": {
"@portabletext/editor": "^1.44.13",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/_exports/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export {AuthBoundary} from '../components/auth/AuthBoundary'
export {
PortableTextEditorProvider,
type PortableTextEditorProviderProps,
} from '../components/PortableTextEditorProvider'
export {SanityApp, type SanityAppProps} from '../components/SanityApp'
export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
Expand Down
224 changes: 224 additions & 0 deletions packages/react/src/components/PortableTextEditorProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import {
type Editor,
type PortableTextBlock,
PortableTextEditable,
type PortableTextSpan,
type SchemaDefinition,
useEditor,
} from '@portabletext/editor'
import {
createSanityInstance,
type DocumentHandle,
getDocumentState,
type SanityInstance,
type StateSource,
} from '@sanity/sdk'
import {act, fireEvent, render, screen} from '@testing-library/react'
import {useEffect} from 'react'
import {beforeEach, describe, expect, it, vi} from 'vitest'

import {useSanityInstance} from '../hooks/context/useSanityInstance'
import {useEditDocument} from '../hooks/document/useEditDocument'
import {PortableTextEditorProvider} from './PortableTextEditorProvider'

// Mock dependencies
vi.mock('../hooks/context/useSanityInstance')
vi.mock('@sanity/sdk', async (importOriginal) => {
const actual = await importOriginal<typeof import('@sanity/sdk')>()
return {
...actual,
getDocumentState: vi.fn(),
}
})
vi.mock('../hooks/document/useEditDocument')

let instance: SanityInstance
const subscribe = vi.fn(() => vi.fn()) as StateSource<unknown>['subscribe'] // Returns a mock unsubscribe function
const getCurrent = vi.fn() as StateSource<unknown>['getCurrent']
const edit = vi.fn()

describe('PortableTextEditorProvider', () => {
const docHandle: DocumentHandle = {
documentId: 'doc1',
documentType: 'author',
projectId: 'test',
dataset: 'test',
}
const path = 'content'
const schemaDefinition: SchemaDefinition = {}
const initialValue: PortableTextBlock[] = [
{
_type: 'block',
_key: 'a',
children: [{_type: 'span', _key: 'a1', text: 'Hello', marks: []}],
markDefs: [],
style: 'normal',
},
]

beforeEach(() => {
vi.clearAllMocks()
instance = createSanityInstance()

vi.mocked(useSanityInstance).mockReturnValue(instance)
vi.mocked(getCurrent).mockReturnValue(initialValue)
vi.mocked(getDocumentState).mockReturnValue({subscribe, getCurrent} as StateSource<unknown>)
vi.mocked(useEditDocument).mockReturnValue(edit)
})

afterEach(() => {
instance.dispose()
})

it('should render children', () => {
render(
<PortableTextEditorProvider {...docHandle} path={path} initialConfig={{schemaDefinition}}>
<div>Test Child</div>
</PortableTextEditorProvider>,
)
expect(screen.getByText('Test Child')).toBeInTheDocument()
})

it('should initialize with correct schema and initial value', () => {
// We can't directly assert props on EditorProvider without complex mocking or introspection
// Instead, we verify that the necessary hooks were called to fetch the initial value.
render(
<PortableTextEditorProvider {...docHandle} path={path} initialConfig={{schemaDefinition}}>
<div />
</PortableTextEditorProvider>,
)

expect(useSanityInstance).toHaveBeenCalledWith(docHandle)
expect(getDocumentState).toHaveBeenCalledWith(instance, docHandle, path)
expect(getCurrent).toHaveBeenCalledTimes(1) // Called once by Provider for initialValue
// Note: UpdateValuePlugin also calls getDocumentState, but we check that separately
})

it('UpdateValuePlugin should setup subscription and edit function on mount', () => {
render(
<PortableTextEditorProvider {...docHandle} path={path} initialConfig={{schemaDefinition}}>
<div />
</PortableTextEditorProvider>,
)

// Called once by Provider, once by Plugin
expect(useSanityInstance).toHaveBeenCalledTimes(2)
expect(useSanityInstance).toHaveBeenNthCalledWith(1, docHandle) // Provider
expect(useSanityInstance).toHaveBeenNthCalledWith(2, docHandle) // Plugin

// Called once by Provider, once by Plugin
expect(getDocumentState).toHaveBeenCalledTimes(2)
expect(getDocumentState).toHaveBeenNthCalledWith(1, instance, docHandle, path) // Provider
expect(getDocumentState).toHaveBeenNthCalledWith(2, instance, docHandle, path) // Plugin

expect(subscribe).toHaveBeenCalledTimes(1) // Called by Plugin
expect(useEditDocument).toHaveBeenCalledTimes(1) // Called by Plugin
expect(useEditDocument).toHaveBeenCalledWith(docHandle, path)
})

it('UpdateValuePlugin subscribe callback should call editor.send', async () => {
// Capture the callback passed to subscribe
let onStoreChanged: (() => void) | undefined = () => {}
vi.mocked(subscribe).mockImplementation((cb) => {
onStoreChanged = cb
return vi.fn() // unsubscribe
})

let editor!: Editor

function CaptureEditor() {
const _editor = useEditor()

useEffect(() => {
editor = _editor
}, [_editor])

return null
}

render(
<PortableTextEditorProvider {...docHandle} path={path} initialConfig={{schemaDefinition}}>
<CaptureEditor />
</PortableTextEditorProvider>,
)

expect(editor).toBeDefined()

// Trigger the subscription callback
const newValue = [{_type: 'block', _key: 'b', children: [{_type: 'span', text: 'Updated'}]}]
await act(async () => {
vi.mocked(getCurrent).mockReturnValue(newValue) // Simulate new value from document state
onStoreChanged?.()
})

const value = await new Promise<PortableTextBlock[] | undefined>((resolve) => {
const subscription = editor.on('value changed', (e) => {
if (e.value?.at(0)?._key === 'b') {
subscription.unsubscribe()
resolve(e.value)
}
})
})

expect(value).toEqual(newValue)
})

it.skip('should call edit function on editor mutation', async () => {
// Set up a promise that resolves when edit is called
let resolveEditPromise: (value: PortableTextBlock[]) => void
// Ensure the Promise itself is typed
const editCalledPromise = new Promise<PortableTextBlock[]>((resolve) => {
resolveEditPromise = resolve
})
vi.mocked(edit).mockClear()
vi.mocked(edit).mockImplementation((value) => {
// Ensure the value passed to the resolver is cast correctly
resolveEditPromise(value as PortableTextBlock[])
})

render(
<PortableTextEditorProvider {...docHandle} path={path} initialConfig={{schemaDefinition}}>
<PortableTextEditable data-testid="pte-editable" />
</PortableTextEditorProvider>,
)

// Use findByTestId to ensure the element is ready
const editableElement = await screen.findByTestId('pte-editable')

// Simulate clicking and then changing input value using fireEvent
const textToType = ' world'
// hi gemini, don't fix this line, it's fine as is
const initialText = (initialValue[0]?.children as PortableTextSpan[])[0]?.text ?? ''
const fullNewText = initialText + textToType
await act(async () => {
fireEvent.click(editableElement) // Simulate click first
fireEvent.input(editableElement, {target: {textContent: fullNewText}}) // Then simulate input
})

// Wait for the edit function to be called (no waitFor needed here, await promise directly)
const editedValue = await editCalledPromise

// Assert that edit was called with the new value
expect(edit).toHaveBeenCalledTimes(1)

// Construct the expected value
const expectedValue: PortableTextBlock[] = [
{
_type: 'block',
_key: expect.any(String),
children: [
{
_type: 'span',
_key: expect.any(String),
text: fullNewText,
marks: [],
},
],
markDefs: [],
style: 'normal',
},
]

expect(editedValue).toEqual(expectedValue)
})
})
68 changes: 68 additions & 0 deletions packages/react/src/components/PortableTextEditorProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
type EditorConfig,
EditorProvider,
type PortableTextBlock,
useEditor,
} from '@portabletext/editor'
import {type DocumentHandle, getDocumentState, type SanityDocument} from '@sanity/sdk'
import {useEffect, useState} from 'react'

import {useSanityInstance} from '../hooks/context/useSanityInstance'
import {useEditDocument} from '../hooks/document/useEditDocument'

function UpdateValuePlugin({path, ...docHandle}: DocumentHandle & {path: string}) {
const instance = useSanityInstance(docHandle)
const editor = useEditor()
const edit = useEditDocument(docHandle, path)

useEffect(() => {
const {getCurrent, subscribe} = getDocumentState<
SanityDocument & Record<string, PortableTextBlock[] | undefined>,
string
>(instance, docHandle, path)

subscribe(() => {
editor.send({type: 'update value', value: getCurrent()})
})
}, [docHandle, editor, instance, path])

useEffect(() => {
const subscription = editor.on('mutation', ({value}) => edit(value))
return () => subscription.unsubscribe()
}, [edit, editor])

return null
}

/**
* @alpha
*/
export interface PortableTextEditorProviderProps extends DocumentHandle {
path: string
initialConfig: EditorConfig
children: React.ReactNode
}

/**
* @alpha
*/
export function PortableTextEditorProvider({
path,
children,
initialConfig,
...docHandle
}: PortableTextEditorProviderProps): React.ReactNode {
const instance = useSanityInstance(docHandle)
const {getCurrent} = getDocumentState<
SanityDocument & Record<string, PortableTextBlock[] | undefined>,
string
>(instance, docHandle, path)
const [initialValue] = useState(getCurrent)

return (
<EditorProvider initialConfig={{initialValue: initialValue ?? [], ...initialConfig}}>
<UpdateValuePlugin path={path} {...docHandle} />
{children}
</EditorProvider>
)
}
Loading
Loading