@sanity/sdk-react 0.0.0-alpha.4 → 0.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,5 +17,9 @@ export {
17
17
  type WindowMessageHandler,
18
18
  } from '../hooks/comlink/useWindowConnection'
19
19
  export {useSanityInstance} from '../hooks/context/useSanityInstance'
20
- export {type UseDocuments, useDocuments} from '../hooks/documentCollection/useDocuments'
21
- export {usePreview, type UsePreviewOptions} from '../hooks/preview/usePreview'
20
+ export {type DocumentCollection, useDocuments} from '../hooks/documentCollection/useDocuments'
21
+ export {
22
+ usePreview,
23
+ type UsePreviewOptions,
24
+ type UsePreviewResults,
25
+ } from '../hooks/preview/usePreview'
@@ -1,11 +1,10 @@
1
- import {AuthStateType, createSanityInstance} from '@sanity/sdk'
2
- import {SanityProvider} from '@sanity/sdk-react/context'
1
+ import {AuthStateType} from '@sanity/sdk'
3
2
  import {useAuthState} from '@sanity/sdk-react/hooks'
4
- import {render, screen, waitFor} from '@testing-library/react'
5
- import React from 'react'
3
+ import {screen, waitFor} from '@testing-library/react'
6
4
  import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'
7
5
 
8
6
  import {AuthBoundary} from './AuthBoundary'
7
+ import {renderWithWrappers} from './authTestHelpers'
9
8
 
10
9
  // Mock hooks
11
10
  vi.mock('../../hooks/auth/useAuthState', () => ({
@@ -36,11 +35,6 @@ vi.mock('./AuthError', async (importOriginal) => {
36
35
  }
37
36
  })
38
37
 
39
- const sanityInstance = createSanityInstance({projectId: 'test-project-id', dataset: 'production'})
40
- const renderWithWrappers = (ui: React.ReactElement) => {
41
- return render(<SanityProvider sanityInstance={sanityInstance}>{ui}</SanityProvider>)
42
- }
43
-
44
38
  describe('AuthBoundary', () => {
45
39
  let consoleErrorSpy: MockInstance
46
40
  beforeEach(() => {
@@ -60,7 +54,7 @@ describe('AuthBoundary', () => {
60
54
  renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
61
55
 
62
56
  // The login screen should show "Choose login provider" by default
63
- expect(screen.getByText('Choose login provider')).toBeInTheDocument()
57
+ expect(screen.getByText('Choose login provider:')).toBeInTheDocument()
64
58
  expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
65
59
  })
66
60
 
@@ -1,9 +1,7 @@
1
- import {createSanityInstance} from '@sanity/sdk'
2
- import {SanityProvider} from '@sanity/sdk-react/context'
3
- import {render, screen} from '@testing-library/react'
4
- import React from 'react'
1
+ import {screen} from '@testing-library/react'
5
2
  import {describe, expect, it, vi} from 'vitest'
6
3
 
4
+ import {renderWithWrappers} from './authTestHelpers'
7
5
  import {Login} from './Login'
8
6
 
9
7
  vi.mock('../../hooks/auth/useLoginUrls', () => ({
@@ -13,15 +11,10 @@ vi.mock('../../hooks/auth/useLoginUrls', () => ({
13
11
  ]),
14
12
  }))
15
13
 
16
- const sanityInstance = createSanityInstance({projectId: 'test-project-id', dataset: 'production'})
17
- const renderWithWrappers = (ui: React.ReactElement) => {
18
- return render(<SanityProvider sanityInstance={sanityInstance}>{ui}</SanityProvider>)
19
- }
20
-
21
14
  describe('Login', () => {
22
15
  it('renders login providers', () => {
23
16
  renderWithWrappers(<Login />)
24
- expect(screen.getByText('Choose login provider')).toBeInTheDocument()
17
+ expect(screen.getByText('Choose login provider:')).toBeInTheDocument()
25
18
  expect(screen.getByRole('link', {name: 'Provider A'})).toHaveAttribute(
26
19
  'href',
27
20
  'https://provider-a.com/auth',
@@ -1,3 +1,4 @@
1
+ import {Box, Button, Flex, Heading, Spinner, Stack} from '@sanity/ui'
1
2
  import {type JSX, Suspense} from 'react'
2
3
 
3
4
  import {useLoginUrls} from '../../hooks/auth/useLoginUrls'
@@ -8,17 +9,26 @@ import {LoginLayout, type LoginLayoutProps} from './LoginLayout'
8
9
  * Renders a list of login options with a loading fallback while providers load.
9
10
  *
10
11
  * @alpha
12
+ * @internal
11
13
  */
12
14
  export function Login({header, footer}: LoginLayoutProps): JSX.Element {
13
15
  return (
14
16
  <LoginLayout header={header} footer={footer}>
15
- <div className="sc-login">
16
- <h1 className="sc-login__title">Choose login provider</h1>
17
+ <Heading as="h6" align="center">
18
+ Choose login provider:
19
+ </Heading>
17
20
 
18
- <Suspense fallback={<div className="sc-login__loading">Loading…</div>}>
19
- <Providers />
20
- </Suspense>
21
- </div>
21
+ <Suspense
22
+ fallback={
23
+ <Box padding={5}>
24
+ <Flex align="center" justify="center">
25
+ <Spinner />
26
+ </Flex>
27
+ </Box>
28
+ }
29
+ >
30
+ <Providers />
31
+ </Suspense>
22
32
  </LoginLayout>
23
33
  )
24
34
  }
@@ -27,12 +37,18 @@ function Providers() {
27
37
  const loginUrls = useLoginUrls()
28
38
 
29
39
  return (
30
- <div className="sc-login-providers">
40
+ <Stack space={3} marginY={5}>
31
41
  {loginUrls.map(({title, url}) => (
32
- <a key={url} href={url}>
33
- {title}
34
- </a>
42
+ <Button
43
+ key={url}
44
+ as="a"
45
+ href={url}
46
+ mode="ghost"
47
+ text={title}
48
+ textAlign="center"
49
+ fontSize={2}
50
+ ></Button>
35
51
  ))}
36
- </div>
52
+ </Stack>
37
53
  )
38
54
  }
@@ -1,14 +1,7 @@
1
- import {createSanityInstance} from '@sanity/sdk'
2
- import {SanityProvider} from '@sanity/sdk-react/context'
3
- import {render, screen, waitFor} from '@testing-library/react'
4
- import React from 'react'
1
+ import {screen, waitFor} from '@testing-library/react'
5
2
  import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'
6
3
 
7
- const sanityInstance = createSanityInstance({projectId: 'test-project-id', dataset: 'production'})
8
-
9
- const renderWithWrappers = (ui: React.ReactElement) => {
10
- return render(<SanityProvider sanityInstance={sanityInstance}>{ui}</SanityProvider>)
11
- }
4
+ import {renderWithWrappers} from './authTestHelpers'
12
5
 
13
6
  // Mock `useHandleCallback`
14
7
  vi.mock('../../hooks/auth/useHandleCallback', () => ({
@@ -1,3 +1,4 @@
1
+ import {Flex, Heading, Spinner} from '@sanity/ui'
1
2
  import {useEffect} from 'react'
2
3
 
3
4
  import {useHandleCallback} from '../../hooks/auth/useHandleCallback'
@@ -27,10 +28,12 @@ export function LoginCallback({header, footer}: LoginLayoutProps): React.ReactNo
27
28
 
28
29
  return (
29
30
  <LoginLayout header={header} footer={footer}>
30
- <div className="sc-login-callback">
31
- <h1 className="sc-login-callback__title">Logging you in…</h1>
32
- <div className="sc-login-callback__loading">Loading…</div>
33
- </div>
31
+ <Heading as="h6" align="center">
32
+ Logging you in
33
+ </Heading>
34
+ <Flex paddingY={5} align="center" justify="center">
35
+ <Spinner />
36
+ </Flex>
34
37
  </LoginLayout>
35
38
  )
36
39
  }
@@ -1,22 +1,14 @@
1
- import {createSanityInstance} from '@sanity/sdk'
2
- import {SanityProvider} from '@sanity/sdk-react/context'
3
- import {fireEvent, render, screen, waitFor} from '@testing-library/react'
4
- import React from 'react'
1
+ import {fireEvent, screen, waitFor} from '@testing-library/react'
5
2
  import {describe, expect, it, vi} from 'vitest'
6
3
 
7
4
  import {AuthError} from './AuthError'
5
+ import {renderWithWrappers} from './authTestHelpers'
8
6
  import {LoginError} from './LoginError'
9
7
 
10
8
  vi.mock('../../hooks/auth/useLogOut', () => ({
11
9
  useLogOut: vi.fn(() => async () => {}),
12
10
  }))
13
11
 
14
- const sanityInstance = createSanityInstance({projectId: 'test-project-id', dataset: 'production'})
15
-
16
- const renderWithWrappers = (ui: React.ReactElement) => {
17
- return render(<SanityProvider sanityInstance={sanityInstance}>{ui}</SanityProvider>)
18
- }
19
-
20
12
  describe('LoginError', () => {
21
13
  it('shows authentication error and retry button', async () => {
22
14
  const mockReset = vi.fn()
@@ -1,3 +1,4 @@
1
+ import {Button, Heading, Stack, Text} from '@sanity/ui'
1
2
  import {useCallback} from 'react'
2
3
  import {type FallbackProps} from 'react-error-boundary'
3
4
 
@@ -32,18 +33,13 @@ export function LoginError({
32
33
 
33
34
  return (
34
35
  <LoginLayout header={header} footer={footer}>
35
- <div className="sc-login-error">
36
- <div className="sc-login-error__content">
37
- <h2 className="sc-login-error__title">Authentication Error</h2>
38
- <p className="sc-login-error__description">
39
- Please try again or contact support if the problem persists.
40
- </p>
41
- </div>
42
-
43
- <button className="sc-login-error__button" onClick={handleRetry}>
44
- Retry
45
- </button>
46
- </div>
36
+ <Stack space={5} marginBottom={5}>
37
+ <Heading as="h6" align="center">
38
+ Authentication Error
39
+ </Heading>
40
+ <Text align="center">Please try again or contact support if the problem persists.</Text>
41
+ <Button mode="ghost" onClick={handleRetry} text="Retry" fontSize={2} />
42
+ </Stack>
47
43
  </LoginLayout>
48
44
  )
49
45
  }
@@ -1,16 +1,9 @@
1
- import {createSanityInstance} from '@sanity/sdk'
2
- import {SanityProvider} from '@sanity/sdk-react/context'
3
- import {render, screen} from '@testing-library/react'
4
- import React from 'react'
1
+ import {screen} from '@testing-library/react'
5
2
  import {describe, expect, it} from 'vitest'
6
3
 
4
+ import {renderWithWrappers} from './authTestHelpers'
7
5
  import {LoginFooter} from './LoginFooter'
8
6
 
9
- const sanityInstance = createSanityInstance({projectId: 'test-project-id', dataset: 'production'})
10
- const renderWithWrappers = (ui: React.ReactElement) => {
11
- return render(<SanityProvider sanityInstance={sanityInstance}>{ui}</SanityProvider>)
12
- }
13
-
14
7
  describe('LoginFooter', () => {
15
8
  it('renders footer links', () => {
16
9
  renderWithWrappers(<LoginFooter />)
@@ -1,5 +1,5 @@
1
1
  import {SanityLogo} from '@sanity/logos'
2
- import {Fragment} from 'react'
2
+ import {Box, Flex, Inline, Text} from '@sanity/ui'
3
3
 
4
4
  const LINKS = [
5
5
  {
@@ -32,20 +32,27 @@ const LINKS = [
32
32
  */
33
33
  export function LoginFooter(): React.ReactNode {
34
34
  return (
35
- <div className="sc-login-footer">
36
- <SanityLogo className="sc-login-footer__logo" />
35
+ <Box>
36
+ <Flex justify="center">
37
+ <SanityLogo />
38
+ </Flex>
37
39
 
38
- <ul className="sc-login-footer__links">
39
- {LINKS.map((link) => (
40
- <Fragment key={link.title}>
41
- <li className="sc-login-footer__link">
42
- <a href={link.url} target="_blank" rel="noopener noreferrer">
40
+ <Flex justify="center">
41
+ <Inline space={2} paddingY={3}>
42
+ {LINKS.map((link) => (
43
+ <Text size={0} key={link.url}>
44
+ <a
45
+ href={link.url}
46
+ target="_blank"
47
+ rel="noopener noreferrer"
48
+ style={{color: 'inherit'}}
49
+ >
43
50
  {link.title}
44
51
  </a>
45
- </li>
46
- </Fragment>
47
- ))}
48
- </ul>
49
- </div>
52
+ </Text>
53
+ ))}
54
+ </Inline>
55
+ </Flex>
56
+ </Box>
50
57
  )
51
58
  }
@@ -1,16 +1,9 @@
1
- import {createSanityInstance} from '@sanity/sdk'
2
- import {SanityProvider} from '@sanity/sdk-react/context'
3
- import {render, screen} from '@testing-library/react'
4
- import React from 'react'
1
+ import {screen} from '@testing-library/react'
5
2
  import {describe, expect, it} from 'vitest'
6
3
 
4
+ import {renderWithWrappers} from './authTestHelpers'
7
5
  import {LoginLayout} from './LoginLayout'
8
6
 
9
- const sanityInstance = createSanityInstance({projectId: 'test-project-id', dataset: 'production'})
10
- const renderWithWrappers = (ui: React.ReactElement) => {
11
- return render(<SanityProvider sanityInstance={sanityInstance}>{ui}</SanityProvider>)
12
- }
13
-
14
7
  describe('LoginLayout', () => {
15
8
  it('renders header, children, and footer', () => {
16
9
  renderWithWrappers(
@@ -1,7 +1,10 @@
1
+ import {Card, Container} from '@sanity/ui'
2
+
1
3
  import {LoginFooter} from './LoginFooter'
2
4
 
3
5
  /**
4
6
  * @alpha
7
+ * @internal
5
8
  */
6
9
  export interface LoginLayoutProps {
7
10
  /** Optional header content rendered at top of card */
@@ -54,16 +57,14 @@ export function LoginLayout({
54
57
  header,
55
58
  }: LoginLayoutProps): React.ReactNode {
56
59
  return (
57
- <div className="sc-login-layout">
58
- <div className="sc-login-layout__container">
59
- <div className="sc-login-layout__card">
60
- {header && <div className="sc-login-layout__card-header">{header}</div>}
60
+ <Container width={0}>
61
+ <Card shadow={1} radius={2} padding={4}>
62
+ {header && header}
61
63
 
62
- {children && <div className="sc-login-layout__card-body">{children}</div>}
63
- </div>
64
+ {children && children}
64
65
 
65
66
  {footer}
66
- </div>
67
- </div>
67
+ </Card>
68
+ </Container>
68
69
  )
69
70
  }
@@ -0,0 +1,18 @@
1
+ import {createSanityInstance} from '@sanity/sdk'
2
+ import {ThemeProvider} from '@sanity/ui'
3
+ import {buildTheme} from '@sanity/ui/theme'
4
+ import {render, type RenderResult} from '@testing-library/react'
5
+ import React from 'react'
6
+
7
+ import {SanityProvider} from '../../context/SanityProvider'
8
+
9
+ const sanityInstance = createSanityInstance({projectId: 'test-project-id', dataset: 'production'})
10
+ const theme = buildTheme()
11
+
12
+ export const renderWithWrappers = (ui: React.ReactElement): RenderResult => {
13
+ return render(
14
+ <SanityProvider sanityInstance={sanityInstance}>
15
+ <ThemeProvider theme={theme}>{ui}</ThemeProvider>
16
+ </SanityProvider>,
17
+ )
18
+ }
@@ -6,11 +6,16 @@ import {useSanityInstance} from '../context/useSanityInstance'
6
6
  /**
7
7
  * @public
8
8
  */
9
- export interface UseDocuments {
9
+ export interface DocumentCollection {
10
+ /** Retrieve more documents matching the provided options */
10
11
  loadMore: () => void
12
+ /** The retrieved document handles of the documents matching the provided options */
11
13
  results: DocumentHandle[]
14
+ /** Whether a retrieval of documents is in flight */
12
15
  isPending: boolean
16
+ /** Whether more documents exist that match the provided options than have been retrieved */
13
17
  hasMore: boolean
18
+ /** The total number of documents in the collection */
14
19
  count: number
15
20
  }
16
21
 
@@ -24,14 +29,56 @@ const STABLE_EMPTY = {
24
29
  }
25
30
 
26
31
  /**
27
- * Hook to get the list of documents for specified options
28
- *
29
32
  * @public
30
33
  *
31
- * @param options - options for the document list
32
- * @returns result of the document list and function to load more
34
+ * The `useDocuments` hook retrieves and provides access to a live collection of documents, optionally filtered, sorted, and matched to a given Content Lake perspective.
35
+ * Because the returned document collection is live, the results will update in real time until the component invoking the hook is unmounted.
36
+ *
37
+ * @param options - Options for narrowing and sorting the document collection
38
+ * @returns The collection of documents matching the provided options (if any), as well as properties describing the collection and a function to load more.
39
+ *
40
+ * @example Retrieving all documents of type 'movie'
41
+ * ```
42
+ * const { results, isPending } = useDocuments({ filter: '_type == "movie"' })
43
+ *
44
+ * return (
45
+ * <div>
46
+ * <h1>Movies</h1>
47
+ * {results && (
48
+ * <ul>
49
+ * {results.map(movie => (<li key={movie._id}>…</li>))}
50
+ * </ul>
51
+ * )}
52
+ * {isPending && <div>Loading movies…</div>}
53
+ * </div>
54
+ * )
55
+ * ```
56
+ *
57
+ * @example Retrieving all movies released since 1980, sorted by release date
58
+ * ```
59
+ * const { results } = useDocuments({
60
+ * filter: '_type == "movie" && releaseDate >= "1980-01-01"',
61
+ * sort: [
62
+ * {
63
+ * field: 'releaseDate',
64
+ * sort: 'asc',
65
+ * },
66
+ * ],
67
+ * })
68
+ *
69
+ * return (
70
+ * <div>
71
+ * <h1>Movies released since 1980</h1>
72
+ * {results && (
73
+ * <ol>
74
+ * {results.map(movie => (<li key={movie._id}>…</li>))}
75
+ * </ol>
76
+ * )}
77
+ * </div>
78
+ * )
79
+ * ```
33
80
  */
34
- export function useDocuments(options: DocumentListOptions = {}): UseDocuments {
81
+ export function useDocuments(options: DocumentListOptions = {}): DocumentCollection {
35
82
  const instance = useSanityInstance()
36
83
 
37
84
  // NOTE: useState is used because it guaranteed to return a stable reference
@@ -45,13 +45,13 @@ const mockDocument: DocumentHandle = {
45
45
 
46
46
  function TestComponent({document}: {document: DocumentHandle}) {
47
47
  const [ref, setRef] = useState<HTMLElement | null>(null)
48
- const [previewValue, pending] = usePreview({document, ref})
48
+ const {results, isPending} = usePreview({document, ref})
49
49
 
50
50
  return (
51
51
  <div ref={setRef}>
52
- <h1>{previewValue.title}</h1>
53
- <p>{previewValue.subtitle}</p>
54
- {pending && <div>Pending...</div>}
52
+ <h1>{results?.title}</h1>
53
+ <p>{results?.subtitle}</p>
54
+ {isPending && <div>Pending...</div>}
55
55
  </div>
56
56
  )
57
57
  }
@@ -74,7 +74,10 @@ describe('usePreview', () => {
74
74
 
75
75
  test('it only subscribes when element is visible', async () => {
76
76
  // Setup initial state
77
- getCurrent.mockReturnValue([{title: 'Initial Title', subtitle: 'Initial Subtitle'}, false])
77
+ getCurrent.mockReturnValue({
78
+ results: {title: 'Initial Title', subtitle: 'Initial Subtitle'},
79
+ isPending: false,
80
+ })
78
81
  const eventsUnsubscribe = vi.fn()
79
82
  subscribe.mockImplementation(() => eventsUnsubscribe)
80
83
 
@@ -135,7 +138,10 @@ describe('usePreview', () => {
135
138
  await act(async () => {
136
139
  intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
137
140
  await resolvePromise
138
- getCurrent.mockReturnValue([{title: 'Resolved Title', subtitle: 'Resolved Subtitle'}, false])
141
+ getCurrent.mockReturnValue({
142
+ results: {title: 'Resolved Title', subtitle: 'Resolved Subtitle'},
143
+ isPending: false,
144
+ })
139
145
  subscriber?.()
140
146
  })
141
147
 
@@ -149,7 +155,10 @@ describe('usePreview', () => {
149
155
  // @ts-expect-error - Intentionally removing IntersectionObserver
150
156
  delete window.IntersectionObserver
151
157
 
152
- getCurrent.mockReturnValue([{title: 'Fallback Title', subtitle: 'Fallback Subtitle'}, false])
158
+ getCurrent.mockReturnValue({
159
+ results: {title: 'Fallback Title', subtitle: 'Fallback Subtitle'},
160
+ isPending: false,
161
+ })
153
162
  subscribe.mockImplementation(() => vi.fn())
154
163
 
155
164
  render(
@@ -15,10 +15,55 @@ export interface UsePreviewOptions {
15
15
  /**
16
16
  * @alpha
17
17
  */
18
- export function usePreview({
19
- document: {_id, _type},
20
- ref,
21
- }: UsePreviewOptions): [PreviewValue, boolean] {
18
+ export interface UsePreviewResults {
19
+ /** The results of resolving the document’s preview values */
20
+ results: PreviewValue
21
+ /** Whether the resolution of the preview values is pending */
22
+ isPending: boolean
23
+ }
24
+
25
+ /**
26
+ * @alpha
27
+ *
28
+ * The `usePreview` hook takes a document (via a `DocumentHandle`) and returns its resolved preview values,
29
+ * including the document’s `title`, `subtitle`, `media`, and `status`. These values are live and will update in realtime.
30
+ * To reduce unnecessary network requests for resolving the preview values, an optional `ref` can be passed to the hook so that preview
31
+ * resolution will only occur if the `ref` is intersecting the current viewport.
32
+ *
33
+ * @param options - The document handle for the document you want to resolve preview values for, and an optional ref
34
+ * @returns The preview values for the given document and a boolean to indicate whether the resolution is pending
35
+ *
36
+ * @example Combining with useDocuments to render a collection of document previews
37
+ * ```
38
+ * // PreviewComponent.jsx
39
+ * export default function PreviewComponent({ document }) {
40
+ * const { results: { title, subtitle, media }, isPending } = usePreview({ document })
41
+ * return isPending ? 'Loading…' : (
42
+ * <article>
43
+ * {media?.type === 'image-asset' ? <img src={media.url} alt='' /> : ''}
44
+ * <h2>{title}</h2>
45
+ * <p>{subtitle}</p>
46
+ * </article>
47
+ * )
48
+ * }
49
+ *
50
+ * // DocumentList.jsx
51
+ * const { results, isPending } = useDocuments({ filter: '_type == "movie"' })
52
+ * return (
53
+ * <div>
54
+ * <h1>Movies</h1>
55
+ * <ul>
56
+ * {isPending ? 'Loading…' : results.map(movie => (
57
+ * <li key={movie._id}>
58
+ * <PreviewComponent document={movie} />
59
+ * </li>
60
+ * ))}
61
+ * </ul>
62
+ * </div>
63
+ * )
64
+ * ```
65
+ */
66
+ export function usePreview({document: {_id, _type}, ref}: UsePreviewOptions): UsePreviewResults {
22
67
  const instance = useSanityInstance()
23
68
 
24
69
  const stateSource = useMemo(
@@ -60,9 +105,9 @@ export function usePreview({
60
105
 
61
106
  // Create getSnapshot function to return current state
62
107
  const getSnapshot = useCallback(() => {
63
- const previewTuple = stateSource.getCurrent()
64
- if (!previewTuple[0]) throw resolvePreview(instance, {document: {_id, _type}})
65
- return previewTuple as [PreviewValue, boolean]
108
+ const currentState = stateSource.getCurrent()
109
+ if (currentState.results === null) throw resolvePreview(instance, {document: {_id, _type}})
110
+ return currentState as UsePreviewResults
66
111
  }, [_id, _type, instance, stateSource])
67
112
 
68
113
  return useSyncExternalStore(subscribe, getSnapshot)