Skip to content
Open
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
147 changes: 147 additions & 0 deletions apps/kitchensink-react/e2e/document-projection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {expect, test} from '@repo/e2e'

test.describe('Document Projection', () => {
test('can switch between different projection types and view data', async ({
page,
getClient,
createDocuments,
getPageContext,
}) => {
const client = getClient()

// Create a book document first (needed for favoriteBooks reference)
const {
documentIds: [bookId],
} = await createDocuments(
[
{
_type: 'book',
title: 'Test Book for Projection',
},
],
{asDraft: false},
)

// Create an author document with favoriteBooks and role
const {
documentIds: [authorId],
} = await createDocuments(
[
{
_type: 'author',
name: 'Test Author Projection',
favoriteBooks: [{_type: 'reference', _ref: bookId}],
role: 'Senior Developer',
},
],
{asDraft: false},
)

// Update the author to reference itself as bestFriend
await client
.patch(authorId)
.set({bestFriend: {_type: 'reference', _ref: authorId}})
.commit()

// Navigate to the document projection route
await page.goto('./document-projection')

// Get the page context for iframe/page detection
const pageContext = await getPageContext(page)

// Wait for the table to be visible
const table = pageContext.getByTestId('projection-table')
await table.waitFor()
await expect(table).toBeVisible()

// Wait for the author row to be visible
const authorRow = pageContext.getByTestId(`author-row-${authorId}`)
await authorRow.waitFor()
await expect(authorRow).toBeVisible()

// Test 1: Favorite Books Projection (default)
const favoriteBooksButton = pageContext.getByRole('button', {name: 'Favorite Books'})
await expect(favoriteBooksButton).toBeVisible()

// Verify favorite books projection data is displayed
const nameCell = pageContext.getByTestId(`projection-name-${authorId}`)
await expect(nameCell).toContainText('Test Author Projection')

const favoriteBooksCell = pageContext.getByTestId(`projection-favorite-books-${authorId}`)
await expect(favoriteBooksCell).toBeVisible()
// Should show the book title or "No favorite books"
const favoriteBooksText = await favoriteBooksCell.textContent()
expect(favoriteBooksText).toBeTruthy()

// Test 2: Switch to Best Friend Projection
const bestFriendButton = pageContext.getByRole('button', {name: 'Best Friend'})
await bestFriendButton.click()

// Wait for the projection to update
await expect(async () => {
const bestFriendCell = pageContext.getByTestId(`projection-best-friend-${authorId}`)
await expect(bestFriendCell).toBeVisible()
}).toPass({timeout: 5000})

// Verify best friend projection data is displayed
const bestFriendCell = pageContext.getByTestId(`projection-best-friend-${authorId}`)
await expect(bestFriendCell).toBeVisible()

const roleCell = pageContext.getByTestId(`projection-role-${authorId}`)
await expect(roleCell).toBeVisible()
await expect(roleCell).toContainText('Senior Developer')

// Verify favorite books cell is no longer visible
await expect(favoriteBooksCell).not.toBeVisible()

// Test 3: Switch to Book Count (Groq Helper) Projection
const bookCountButton = pageContext.getByRole('button', {name: 'Book Count'})
await bookCountButton.click()

// Wait for the projection to update
await expect(async () => {
const bookCountCell = pageContext.getByTestId(`projection-book-count-${authorId}`)
await expect(bookCountCell).toBeVisible()
}).toPass({timeout: 5000})

// Verify book count projection data is displayed
const bookCountCell = pageContext.getByTestId(`projection-book-count-${authorId}`)
await expect(bookCountCell).toBeVisible()
// Should show "1 books" or "0 books"
const bookCountText = await bookCountCell.textContent()
expect(bookCountText).toMatch(/\d+ books/)

const hasBooksCell = pageContext.getByTestId(`projection-has-books-${authorId}`)
await expect(hasBooksCell).toBeVisible()
// Should show "Yes" or "No"
const hasBooksText = await hasBooksCell.textContent()
expect(['Yes', 'No']).toContain(hasBooksText)

// Verify best friend and role cells are no longer visible
await expect(bestFriendCell).not.toBeVisible()
await expect(roleCell).not.toBeVisible()

// Test 4: Switch back to Favorite Books Projection
await favoriteBooksButton.click()

// Wait for the projection to update
await expect(async () => {
const favoriteBooksCellAgain = pageContext.getByTestId(
`projection-favorite-books-${authorId}`,
)
await expect(favoriteBooksCellAgain).toBeVisible()
}).toPass({timeout: 5000})

// Verify favorite books projection is displayed again
const favoriteBooksCellAgain = pageContext.getByTestId(`projection-favorite-books-${authorId}`)
await expect(favoriteBooksCellAgain).toBeVisible()

// Verify book count cells are no longer visible
await expect(bookCountCell).not.toBeVisible()
await expect(hasBooksCell).not.toBeVisible()

// Verify the name cell is still visible (should always be visible)
await expect(nameCell).toBeVisible()
await expect(nameCell).toContainText('Test Author Projection')
})
})
Original file line number Diff line number Diff line change
@@ -1,54 +1,84 @@
import {DocumentHandle, useDocumentProjection, usePaginatedDocuments} from '@sanity/sdk-react'
import {Box, Button, Card, Flex, Label, Spinner, Stack, Text, TextInput} from '@sanity/ui'
import {defineProjection} from 'groq'
import groq, {defineProjection} from 'groq'
import {JSX, ReactNode, Suspense, useRef, useState} from 'react'
import {ErrorBoundary} from 'react-error-boundary'

// Import the custom table components
import {Table, TD, TH, TR} from '../components/TableElements'

interface PossibleAuthorProjections {
name?: string
favoriteBookTitles: string[]
bestFriend?: {
name?: string
}
role?: string
bookCount?: number
hasBooks?: boolean
}

// Component for displaying projection data with proper error handling
function ProjectionData({
docHandle,
useFirstProjection,
projectionType,
}: {
docHandle: DocumentHandle<'author'>
useFirstProjection: boolean
projectionType: 'favoriteBooks' | 'bestFriend' | 'groqHelper'
}) {
const authorProjection = defineProjection(`{
const projections: Record<string, string> = {
favoriteBooks: `{
name,
"favoriteBookTitles": favoriteBooks[]->{title}.title
}`)

const bestFriendProjection = defineProjection(`{
}`,
bestFriend: defineProjection(`{
name,
'bestFriendName': bestFriend->{name}.name,
role
}`)
}`),
groqHelper: groq`{
name,
"bookCount": count(favoriteBooks),
"hasBooks": count(favoriteBooks) > 0
}`,
}

const ref = useRef<HTMLTableCellElement>(null)
const projection = useFirstProjection ? authorProjection : bestFriendProjection
const {data} = useDocumentProjection({
const projection = projections[projectionType]
const {data} = useDocumentProjection<PossibleAuthorProjections>({
...docHandle,
ref,
projection,
})

return (
<>
<TD ref={ref} padding={2}>
<TD ref={ref} padding={2} data-testid={`projection-name-${docHandle.documentId}`}>
{data.name || 'Untitled'}
</TD>
{'favoriteBookTitles' in data ? (
{projectionType === 'favoriteBooks' ? (
<>
<TD padding={2}>
<TD padding={2} data-testid={`projection-favorite-books-${docHandle.documentId}`}>
{data.favoriteBookTitles?.filter(Boolean).join(', ') || 'No favorite books'}
</TD>
</>
) : projectionType === 'bestFriend' ? (
<>
<TD padding={2} data-testid={`projection-best-friend-${docHandle.documentId}`}>
{data.bestFriend?.name || 'No best friend'}
</TD>
<TD padding={2} data-testid={`projection-role-${docHandle.documentId}`}>
{data.role || 'No role'}
</TD>
</>
) : (
<>
<TD padding={2}>{data.bestFriendName || 'No best friend'}</TD>
<TD padding={2}>{data.role || 'No role'}</TD>
<TD padding={2} data-testid={`projection-book-count-${docHandle.documentId}`}>
{data.bookCount ?? 0} books
</TD>
<TD padding={2} data-testid={`projection-has-books-${docHandle.documentId}`}>
{data.hasBooks ? 'Yes' : 'No'}
</TD>
</>
)}
</>
Expand Down Expand Up @@ -84,16 +114,16 @@ function ProjectionError({error}: {error: Error}): ReactNode {
// Component for displaying a single author row with projection data
function AuthorRow({
docHandle,
useFirstProjection,
projectionType,
}: {
docHandle: DocumentHandle<'author'>
useFirstProjection: boolean
projectionType: 'favoriteBooks' | 'bestFriend' | 'groqHelper'
}) {
return (
<TR>
<TR data-testid={`author-row-${docHandle.documentId}`}>
<ErrorBoundary fallbackRender={({error}) => <ProjectionError error={error} />}>
<Suspense fallback={<ProjectionFallback />}>
<ProjectionData docHandle={docHandle} useFirstProjection={useFirstProjection} />
<ProjectionData docHandle={docHandle} projectionType={projectionType} />
</Suspense>
</ErrorBoundary>
</TR>
Expand Down Expand Up @@ -209,7 +239,9 @@ function PaginationControls({
export function DocumentProjectionRoute(): JSX.Element {
const [searchTerm, setSearchTerm] = useState('')
const [pageSize, setPageSize] = useState(5)
const [useFirstProjection, setUseFirstProjection] = useState(true)
const [projectionType, setProjectionType] = useState<
'favoriteBooks' | 'bestFriend' | 'groqHelper'
>('favoriteBooks')

const {
data,
Expand Down Expand Up @@ -305,41 +337,53 @@ export function DocumentProjectionRoute(): JSX.Element {
/>

<Box padding={4}>
<Button
onClick={() => setUseFirstProjection(!useFirstProjection)}
text={
useFirstProjection
? 'Switch to Best Friend Projection'
: 'Switch to First Author Projection'
}
/>
<Flex gap={2}>
<Button
onClick={() => setProjectionType('favoriteBooks')}
mode={projectionType === 'favoriteBooks' ? 'default' : 'ghost'}
text="Favorite Books"
data-testid="projection-button-favorite-books"
/>
<Button
onClick={() => setProjectionType('bestFriend')}
mode={projectionType === 'bestFriend' ? 'default' : 'ghost'}
text="Best Friend"
data-testid="projection-button-best-friend"
/>
<Button
onClick={() => setProjectionType('groqHelper')}
mode={projectionType === 'groqHelper' ? 'default' : 'ghost'}
text="Book Count"
data-testid="projection-button-book-count"
/>
</Flex>
</Box>

<Table style={{opacity: isPending ? 0.5 : 1}}>
<Table style={{opacity: isPending ? 0.5 : 1}} data-testid="projection-table">
<thead>
<TR>
<TH padding={2}>Name</TH>
{useFirstProjection ? (
{projectionType === 'favoriteBooks' ? (
<>
<TH padding={2}>Address</TH>
<TH padding={2}>Favorite Books</TH>
</>
) : (
) : projectionType === 'bestFriend' ? (
<>
<TH padding={2}>Best Friend</TH>
<TH padding={2}>Role</TH>
</>
) : (
<>
<TH padding={2}>Book Count</TH>
<TH padding={2}>Has Books</TH>
</>
)}
</TR>
</thead>
<tbody>
{data.length > 0 ? (
data.map((doc) => (
<AuthorRow
key={doc.documentId}
docHandle={doc}
useFirstProjection={useFirstProjection}
/>
<AuthorRow key={doc.documentId} docHandle={doc} projectionType={projectionType} />
))
) : (
<TR>
Expand Down
17 changes: 14 additions & 3 deletions packages/react/src/hooks/projection/useDocumentProjection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {type DocumentHandle, getProjectionState, resolveProjection} from '@sanity/sdk'
import {type SanityProjectionResult} from 'groq'
import {useCallback, useSyncExternalStore} from 'react'
import {useCallback, useMemo, useSyncExternalStore} from 'react'
import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'

import {useSanityInstance} from '../context/useSanityInstance'
Expand Down Expand Up @@ -177,10 +177,21 @@ export function useDocumentProjection<TData extends object>({
...docHandle
}: useDocumentProjectionOptions): useDocumentProjectionResults<TData> {
const instance = useSanityInstance(docHandle)
const stateSource = getProjectionState<TData>(instance, {...docHandle, projection})

// Normalize projection string to handle template literals with whitespace
// This ensures that the same projection content produces the same state source
// even if the string reference changes (e.g., from inline template literals)
const normalizedProjection = useMemo(() => projection.trim(), [projection])

// Memoize stateSource based on normalized projection and docHandle properties
// This prevents creating a new StateSource on every render when projection content is the same
const stateSource = useMemo(
() => getProjectionState<TData>(instance, {...docHandle, projection: normalizedProjection}),
[instance, normalizedProjection, docHandle],
)

if (stateSource.getCurrent()?.data === null) {
throw resolveProjection(instance, {...docHandle, projection})
throw resolveProjection(instance, {...docHandle, projection: normalizedProjection})
}

// Create subscribe function for useSyncExternalStore
Expand Down
Loading