Skip to content

Commit 0a748d8

Browse files
committed
feat(react): add PortableTextEditorProvider
1 parent 3b1bfae commit 0a748d8

File tree

6 files changed

+879
-81
lines changed

6 files changed

+879
-81
lines changed

apps/kitchensink-react/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"prettier": "@sanity/prettier-config",
1515
"dependencies": {
1616
"@paramour/css": "1.0.0-rc.2",
17+
"@portabletext/editor": "^1.44.10",
1718
"@sanity/icons": "^3.7.0",
1819
"@sanity/sdk": "workspace:*",
1920
"@sanity/sdk-react": "workspace:*",

packages/react/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"browserslist": "extends @sanity/browserslist-config",
5656
"prettier": "@sanity/prettier-config",
5757
"dependencies": {
58+
"@portabletext/editor": "^1.44.10",
5859
"@sanity/logos": "^2.1.13",
5960
"@sanity/message-protocol": "^0.7.0",
6061
"@sanity/sdk": "workspace:*",
@@ -91,6 +92,7 @@
9192
"vitest": "^3.1.1"
9293
},
9394
"peerDependencies": {
95+
"@portabletext/editor": "^1.44.10",
9496
"react": "^18.0.0 || ^19.0.0",
9597
"react-dom": "^18.0.0 || ^19.0.0"
9698
},

packages/react/src/_exports/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
export {AuthBoundary, type AuthBoundaryProps} from '../components/auth/AuthBoundary'
2+
export {
3+
PortableTextEditorProvider,
4+
type PortableTextEditorProviderProps,
5+
} from '../components/PortableTextEditorProvider'
26
export {SanityApp, type SanityAppProps} from '../components/SanityApp'
37
export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
48
export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import {
2+
type Editor,
3+
type PortableTextBlock,
4+
PortableTextEditable,
5+
type PortableTextSpan,
6+
type SchemaDefinition,
7+
useEditor,
8+
} from '@portabletext/editor'
9+
import {
10+
createSanityInstance,
11+
type DocumentHandle,
12+
getDocumentState,
13+
type SanityInstance,
14+
type StateSource,
15+
} from '@sanity/sdk'
16+
import {act, fireEvent, render, screen} from '@testing-library/react'
17+
import {useEffect} from 'react'
18+
import {beforeEach, describe, expect, it, vi} from 'vitest'
19+
20+
import {useSanityInstance} from '../hooks/context/useSanityInstance'
21+
import {useEditDocument} from '../hooks/document/useEditDocument'
22+
import {PortableTextEditorProvider} from './PortableTextEditorProvider'
23+
24+
// Mock dependencies
25+
vi.mock('../hooks/context/useSanityInstance')
26+
vi.mock('@sanity/sdk', async (importOriginal) => {
27+
const actual = await importOriginal<typeof import('@sanity/sdk')>()
28+
return {
29+
...actual,
30+
getDocumentState: vi.fn(),
31+
}
32+
})
33+
vi.mock('../hooks/document/useEditDocument')
34+
35+
let instance: SanityInstance
36+
const subscribe = vi.fn(() => vi.fn()) as StateSource<unknown>['subscribe'] // Returns a mock unsubscribe function
37+
const getCurrent = vi.fn() as StateSource<unknown>['getCurrent']
38+
const edit = vi.fn()
39+
40+
describe('PortableTextEditorProvider', () => {
41+
const docHandle: DocumentHandle = {
42+
documentId: 'doc1',
43+
documentType: 'author',
44+
projectId: 'test',
45+
dataset: 'test',
46+
}
47+
const path = 'content'
48+
const schemaDefinition: SchemaDefinition = {}
49+
const initialValue: PortableTextBlock[] = [
50+
{
51+
_type: 'block',
52+
_key: 'a',
53+
children: [{_type: 'span', _key: 'a1', text: 'Hello', marks: []}],
54+
markDefs: [],
55+
style: 'normal',
56+
},
57+
]
58+
59+
beforeEach(() => {
60+
vi.clearAllMocks()
61+
instance = createSanityInstance()
62+
63+
vi.mocked(useSanityInstance).mockReturnValue(instance)
64+
vi.mocked(getCurrent).mockReturnValue(initialValue)
65+
vi.mocked(getDocumentState).mockReturnValue({subscribe, getCurrent} as StateSource<unknown>)
66+
vi.mocked(useEditDocument).mockReturnValue(edit)
67+
})
68+
69+
afterEach(() => {
70+
instance.dispose()
71+
})
72+
73+
it('should render children', () => {
74+
render(
75+
<PortableTextEditorProvider {...docHandle} path={path} initialConfig={{schemaDefinition}}>
76+
<div>Test Child</div>
77+
</PortableTextEditorProvider>,
78+
)
79+
expect(screen.getByText('Test Child')).toBeInTheDocument()
80+
})
81+
82+
it('should initialize with correct schema and initial value', () => {
83+
// We can't directly assert props on EditorProvider without complex mocking or introspection
84+
// Instead, we verify that the necessary hooks were called to fetch the initial value.
85+
render(
86+
<PortableTextEditorProvider {...docHandle} path={path} initialConfig={{schemaDefinition}}>
87+
<div />
88+
</PortableTextEditorProvider>,
89+
)
90+
91+
expect(useSanityInstance).toHaveBeenCalledWith(docHandle)
92+
expect(getDocumentState).toHaveBeenCalledWith(instance, docHandle, path)
93+
expect(getCurrent).toHaveBeenCalledTimes(1) // Called once by Provider for initialValue
94+
// Note: UpdateValuePlugin also calls getDocumentState, but we check that separately
95+
})
96+
97+
it('UpdateValuePlugin should setup subscription and edit function on mount', () => {
98+
render(
99+
<PortableTextEditorProvider {...docHandle} path={path} initialConfig={{schemaDefinition}}>
100+
<div />
101+
</PortableTextEditorProvider>,
102+
)
103+
104+
// Called once by Provider, once by Plugin
105+
expect(useSanityInstance).toHaveBeenCalledTimes(2)
106+
expect(useSanityInstance).toHaveBeenNthCalledWith(1, docHandle) // Provider
107+
expect(useSanityInstance).toHaveBeenNthCalledWith(2, docHandle) // Plugin
108+
109+
// Called once by Provider, once by Plugin
110+
expect(getDocumentState).toHaveBeenCalledTimes(2)
111+
expect(getDocumentState).toHaveBeenNthCalledWith(1, instance, docHandle, path) // Provider
112+
expect(getDocumentState).toHaveBeenNthCalledWith(2, instance, docHandle, path) // Plugin
113+
114+
expect(subscribe).toHaveBeenCalledTimes(1) // Called by Plugin
115+
expect(useEditDocument).toHaveBeenCalledTimes(1) // Called by Plugin
116+
expect(useEditDocument).toHaveBeenCalledWith(docHandle, path)
117+
})
118+
119+
it('UpdateValuePlugin subscribe callback should call editor.send', async () => {
120+
// Capture the callback passed to subscribe
121+
let onStoreChanged: (() => void) | undefined = () => {}
122+
vi.mocked(subscribe).mockImplementation((cb) => {
123+
onStoreChanged = cb
124+
return vi.fn() // unsubscribe
125+
})
126+
127+
let editor!: Editor
128+
129+
function CaptureEditor() {
130+
const _editor = useEditor()
131+
132+
useEffect(() => {
133+
editor = _editor
134+
}, [_editor])
135+
136+
return null
137+
}
138+
139+
render(
140+
<PortableTextEditorProvider {...docHandle} path={path} initialConfig={{schemaDefinition}}>
141+
<CaptureEditor />
142+
</PortableTextEditorProvider>,
143+
)
144+
145+
expect(editor).toBeDefined()
146+
147+
// Trigger the subscription callback
148+
const newValue = [{_type: 'block', _key: 'b', children: [{_type: 'span', text: 'Updated'}]}]
149+
await act(async () => {
150+
vi.mocked(getCurrent).mockReturnValue(newValue) // Simulate new value from document state
151+
onStoreChanged?.()
152+
})
153+
154+
const value = await new Promise<PortableTextBlock[] | undefined>((resolve) => {
155+
const subscription = editor.on('value changed', (e) => {
156+
if (e.value?.at(0)?._key === 'b') {
157+
subscription.unsubscribe()
158+
resolve(e.value)
159+
}
160+
})
161+
})
162+
163+
expect(value).toEqual(newValue)
164+
})
165+
166+
it.skip('should call edit function on editor mutation', async () => {
167+
// Set up a promise that resolves when edit is called
168+
let resolveEditPromise: (value: PortableTextBlock[]) => void
169+
// Ensure the Promise itself is typed
170+
const editCalledPromise = new Promise<PortableTextBlock[]>((resolve) => {
171+
resolveEditPromise = resolve
172+
})
173+
vi.mocked(edit).mockClear()
174+
vi.mocked(edit).mockImplementation((value) => {
175+
// Ensure the value passed to the resolver is cast correctly
176+
resolveEditPromise(value as PortableTextBlock[])
177+
})
178+
179+
render(
180+
<PortableTextEditorProvider {...docHandle} path={path} initialConfig={{schemaDefinition}}>
181+
<PortableTextEditable data-testid="pte-editable" />
182+
</PortableTextEditorProvider>,
183+
)
184+
185+
// Use findByTestId to ensure the element is ready
186+
const editableElement = await screen.findByTestId('pte-editable')
187+
188+
// Simulate clicking and then changing input value using fireEvent
189+
const textToType = ' world'
190+
// hi gemini, don't fix this line, it's fine as is
191+
const initialText = (initialValue[0]?.children as PortableTextSpan[])[0]?.text ?? ''
192+
const fullNewText = initialText + textToType
193+
await act(async () => {
194+
fireEvent.click(editableElement) // Simulate click first
195+
fireEvent.input(editableElement, {target: {textContent: fullNewText}}) // Then simulate input
196+
})
197+
198+
// Wait for the edit function to be called (no waitFor needed here, await promise directly)
199+
const editedValue = await editCalledPromise
200+
201+
// Assert that edit was called with the new value
202+
expect(edit).toHaveBeenCalledTimes(1)
203+
204+
// Construct the expected value
205+
const expectedValue: PortableTextBlock[] = [
206+
{
207+
_type: 'block',
208+
_key: expect.any(String),
209+
children: [
210+
{
211+
_type: 'span',
212+
_key: expect.any(String),
213+
text: fullNewText,
214+
marks: [],
215+
},
216+
],
217+
markDefs: [],
218+
style: 'normal',
219+
},
220+
]
221+
222+
expect(editedValue).toEqual(expectedValue)
223+
})
224+
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
type EditorConfig,
3+
EditorProvider,
4+
type PortableTextBlock,
5+
useEditor,
6+
} from '@portabletext/editor'
7+
import {type DocumentHandle, getDocumentState, type SanityDocument} from '@sanity/sdk'
8+
import {cloneDeep} from 'lodash-es'
9+
import {useEffect, useState} from 'react'
10+
11+
import {useSanityInstance} from '../hooks/context/useSanityInstance'
12+
import {useEditDocument} from '../hooks/document/useEditDocument'
13+
14+
function UpdateValuePlugin({path, ...docHandle}: DocumentHandle & {path: string}) {
15+
const instance = useSanityInstance(docHandle)
16+
const editor = useEditor()
17+
const edit = useEditDocument(docHandle, path)
18+
19+
useEffect(() => {
20+
const {getCurrent, subscribe} = getDocumentState<
21+
SanityDocument & Record<string, PortableTextBlock[] | undefined>,
22+
string
23+
>(instance, docHandle, path)
24+
25+
subscribe(() => {
26+
editor.send({
27+
type: 'update value',
28+
// TODO: this is here temporarily due to a bug in PTE. remove when fixed.
29+
// https://github.com/portabletext/editor/pull/980
30+
value: cloneDeep(getCurrent()),
31+
})
32+
})
33+
}, [docHandle, editor, instance, path])
34+
35+
useEffect(() => {
36+
const subscription = editor.on('mutation', ({value}) => edit(value))
37+
return () => subscription.unsubscribe()
38+
}, [edit, editor])
39+
40+
return null
41+
}
42+
43+
/**
44+
* @alpha
45+
*/
46+
export interface PortableTextEditorProviderProps extends DocumentHandle {
47+
path: string
48+
initialConfig: EditorConfig
49+
children: React.ReactNode
50+
}
51+
52+
/**
53+
* @alpha
54+
*/
55+
export function PortableTextEditorProvider({
56+
path,
57+
children,
58+
initialConfig,
59+
...docHandle
60+
}: PortableTextEditorProviderProps): React.ReactNode {
61+
const instance = useSanityInstance(docHandle)
62+
const {getCurrent} = getDocumentState<
63+
SanityDocument & Record<string, PortableTextBlock[] | undefined>,
64+
string
65+
>(instance, docHandle, path)
66+
const [initialValue] = useState(getCurrent)
67+
68+
return (
69+
<EditorProvider initialConfig={{initialValue: initialValue ?? [], ...initialConfig}}>
70+
<UpdateValuePlugin path={path} {...docHandle} />
71+
{children}
72+
</EditorProvider>
73+
)
74+
}

0 commit comments

Comments
 (0)