feat(studio): notebook query and mutation hooks (#48907)

## Summary

Implements the "notebook query and mutation hooks" step of the notebooks
data layer:

- `data/content/notebooks/notebook-query.ts` —
`getNotebook`/`useNotebookQuery`, wrapping the existing `getContentById`
and narrowing to `type: 'notebook'`.
- `data/content/notebooks/notebooks-infinite-query.ts` —
`useNotebooksInfiniteQuery`, a typed wrapper over
`useContentInfiniteQuery` narrowing pages to notebook rows.
- `data/content/notebooks/notebook-upsert-mutation.ts` —
`createNotebook`/`updateNotebook` + their mutation hooks, PUTting
through the existing `upsertContent`.

Write-path correctness, worked out while building the mutation hooks:

- Cell `id`s are always backend-generated, never client-supplied — a
brand-new cell has no `id` at all; an existing cell being kept/edited in
an update keeps its real id so the backend can diff it against the
previous version. `notebook-schema.ts` gains
`writableCellSchema`/`writableNotebookSchema` (ids optional per cell)
and `WritableCell`/`WritableNotebook` types, derived from `z.infer` of
those schemas rather than hand-duplicated, with only the `sql` field
re-branded per cell type via a small distributive conditional type.
- Cell SQL at this write boundary must already be
`SafeSqlFragment`/`SafeLogSqlFragment` (proven user-authored at a
save/run event handler), not `unchecked_sql` — matching the
`safe-sql-execution` skill's provenance model.
- `content-remap.ts`'s notebook `unmapSqlContentField` branch is
simplified to a passthrough: notebook writes only ever arrive already
wire-shaped via `createNotebook`/`updateNotebook`, so there's nothing
left to unmap.

Note: this was originally stacked on
`feature/notebooks-types-convergence`, but that branch merged into
`master` (#48905) while this PR was in progress, so it's rebased
directly onto `master` now.

## Test plan

- [x] `pnpm --filter studio run typecheck` passes
- [x] `pnpm --filter studio exec vitest run data/content/notebooks
data/content/content-remap.test.ts` — 38/38 passing
- [x] `pnpm --filter studio exec eslint` clean on all touched files

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added notebook listing with pagination, filtering, sorting, and
project-specific queries.
  * Added notebook retrieval for viewing individual notebooks.
  * Added notebook creation and editing with automatic content refresh.
* Added support for preserving cell IDs and safely handling SQL content.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-08-10 15:32:46 -04:00
1 parent 0111aa371b
commit 1296a1c745
10 files changed
+644 -32

No files matched your search

+37 -6
View File
@@ -231,6 +231,43 @@ describe('unmapSqlContentField', () => {
})
})
describe('unmapSqlContentField (notebooks)', () => {
// Notebook content only ever reaches `unmapSqlContentField` via createNotebook/updateNotebook,
// which always hand it a WritableNotebook — plain `sql` per cell, `id` present only for
// existing cells. That's already wire-shaped, so this is a pure passthrough.
it('returns notebook content completely unchanged', () => {
const writableNotebook = {
id: 'e4bbee88-8d2b-4de7-aa5c-5aa8ac270b55',
type: 'notebook' as const,
content: {
schema_version: 1 as const,
cells: [
{ _tag: 'markdown_cell' as const, text: '# New cell' },
{
_tag: 'database_cell' as const,
id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22',
sql: 'select * from auth.users limit 100',
row_limit: 100,
},
{
_tag: 'log_cell' as const,
sql: "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10",
time_range: {
_tag: 'relative_time_range' as const,
unit: 'hour' as const,
amount: 1,
},
},
],
},
}
const result = unmapSqlContentField(writableNotebook)
expect(result).toBe(writableNotebook)
})
})
describe('remap/unmap round-trip', () => {
it('returns the original content shape after remap then unmap for sql snippets', () => {
const result = unmapSqlContentField(remapSqlContentField(SQL_SNIPPET))
@@ -243,10 +280,4 @@ describe('remap/unmap round-trip', () => {
expect(result.content).toEqual(LOG_SQL_SNIPPET.content)
})
it('returns the original content shape after remap then unmap for notebooks', () => {
const result = unmapSqlContentField(remapSqlContentField(NOTEBOOK))
expect(result.content).toEqual(NOTEBOOK.content)
})
})
+5 -23
View File
@@ -11,12 +11,7 @@
// (data/content/notebooks/notebook-schema.ts) instead of the single-field swap below.
import { untrustedSql } from '@supabase/pg-meta'
import {
notebookDomainSchema,
type Cell,
type CellWire,
type NotebookContent,
} from './notebooks/notebook-schema'
import { notebookDomainSchema } from './notebooks/notebook-schema'
import type { SnippetStatus } from './snippet-status'
import type { SnippetWithContent } from './sql-folders-query'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
@@ -29,20 +24,6 @@ function isNotebookContentType(type: string): type is 'notebook' {
return type === 'notebook'
}
// Reverse of the per-cell branding `notebookDomainSchema` applies on the way in: strips
// `unchecked_sql` back to a plain `sql` field for the wire.
function unmapNotebookCell(cell: Cell): CellWire {
switch (cell._tag) {
case 'markdown_cell':
return cell
case 'database_cell':
case 'log_cell': {
const { unchecked_sql, ...rest } = cell
return { ...rest, sql: unchecked_sql }
}
}
}
export function remapSqlContentField<T extends { type: string }>(item: T): T {
if (isNotebookContentType(item.type)) {
if (!('content' in item)) return item
@@ -77,9 +58,10 @@ export function remapWireSnippet(row: unknown, status: SnippetStatus): SnippetWi
// Reverse remap: `unchecked_sql` → `sql` before sending to the API.
export function unmapSqlContentField<T extends { type: string }>(item: T): T {
if (isNotebookContentType(item.type)) {
if (!('content' in item)) return item
const content = item.content as NotebookContent
return { ...item, content: { ...content, cells: content.cells.map(unmapNotebookCell) } } as T
// Notebook content only ever reaches this function via createNotebook/updateNotebook,
// which always hand it a WritableNotebook — plain `sql` per cell, `id` present only
// for existing cells. That's already wire-shaped, so there's nothing to unmap.
return item
}
if (!isSqlContentType(item.type)) return item
if (!('content' in item)) return item
@@ -0,0 +1,72 @@
import { HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { getNotebook } from './notebook-query'
import type { components } from '@/data/api'
import { addAPIMock } from '@/tests/lib/msw'
const NOTEBOOK_ID = 'd3aadd77-7c3c-4de7-aa5c-5aa8ac270b44'
// `type: 'notebook'` isn't in the generated GetUserContentByIdResponse['type'] enum yet (same
// gap tracked by the ContentBase TODO in content-query.ts), so the mock body needs a cast.
const NOTEBOOK_ROW = {
id: NOTEBOOK_ID,
type: 'notebook',
name: 'Signup funnel',
description: '',
favorite: false,
folder_id: null,
inserted_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
visibility: 'project',
owner_id: 1,
project_id: 1,
content: {
schema_version: 1,
cells: [],
},
}
describe('getNotebook', () => {
it('returns notebook data when the fetched content is type: notebook', async () => {
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/content/item/:id',
response: () =>
HttpResponse.json<components['schemas']['GetUserContentByIdResponse']>(
NOTEBOOK_ROW as unknown as components['schemas']['GetUserContentByIdResponse']
),
})
const result = await getNotebook({ projectRef: 'default', id: NOTEBOOK_ID })
expect(result.type).toBe('notebook')
expect(result.content).toEqual({ schema_version: 1, cells: [] })
})
it('throws when the fetched content is not a notebook', async () => {
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/content/item/:id',
response: () =>
HttpResponse.json<components['schemas']['GetUserContentByIdResponse']>({
id: NOTEBOOK_ID,
type: 'report',
name: 'A report',
description: '',
favorite: false,
folder_id: null,
inserted_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
visibility: 'project',
owner_id: 1,
project_id: 1,
content: {},
}),
})
await expect(getNotebook({ projectRef: 'default', id: NOTEBOOK_ID })).rejects.toThrow(
/is not a notebook/
)
})
})
@@ -0,0 +1,40 @@
import { useQuery } from '@tanstack/react-query'
import { getContentById } from '../content-id-query'
import { contentKeys } from '../keys'
import type { Notebooks, ResponseError, UseCustomQueryOptions } from '@/types'
export type NotebookVariables = { projectRef?: string; id?: string }
export type NotebookError = ResponseError
export async function getNotebook(
{ projectRef, id }: NotebookVariables,
signal?: AbortSignal,
headers?: HeadersInit
) {
const data = await getContentById({ projectRef, id }, signal, headers)
// api-types doesn't have 'notebook' in GetUserContentByIdResponse['type'] yet — same gap
// tracked by the ContentBase TODO in content-query.ts — so this narrowing can't be static.
if ((data.type as string) !== 'notebook') {
throw new Error(`Content ${id} is not a notebook (got type: ${data.type})`)
}
return data as unknown as Omit<typeof data, 'type' | 'content'> & {
type: 'notebook'
content: Notebooks.Content
}
}
export type NotebookData = Awaited<ReturnType<typeof getNotebook>>
export const useNotebookQuery = <TData = NotebookData>(
{ projectRef, id }: NotebookVariables,
{ enabled = true, ...options }: UseCustomQueryOptions<NotebookData, NotebookError, TData> = {}
) =>
useQuery<NotebookData, NotebookError, TData>({
queryKey: contentKeys.resource(projectRef, id),
queryFn: ({ signal }) => getNotebook({ projectRef, id }, signal),
enabled: enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined',
...options,
})
@@ -1,7 +1,12 @@
import { untrustedSql } from '@supabase/pg-meta'
import { describe, expect, it } from 'vitest'
import { agentNotebookSchema, notebookDomainSchema, notebookSchema } from './notebook-schema'
import {
agentNotebookSchema,
notebookDomainSchema,
notebookSchema,
writableNotebookSchema,
} from './notebook-schema'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
const FULL_NOTEBOOK = {
@@ -134,6 +139,79 @@ describe('agentNotebookSchema', () => {
})
})
describe('writableNotebookSchema', () => {
it('accepts a notebook where every cell lacks an id (create-shaped)', () => {
const result = writableNotebookSchema.safeParse({
schema_version: 1,
cells: [
{ _tag: 'markdown_cell', text: '# Signup funnel' },
{ _tag: 'database_cell', sql: 'select * from auth.users limit 100', row_limit: 100 },
{
_tag: 'log_cell',
sql: "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10",
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
},
],
})
expect(result.success).toBe(true)
})
it('accepts a notebook with a mix of cells with and without an id (update-shaped)', () => {
const result = writableNotebookSchema.safeParse({
schema_version: 1,
cells: [
{
_tag: 'database_cell',
id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22',
sql: 'select * from auth.users limit 100',
row_limit: 100,
},
{
_tag: 'log_cell',
sql: "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10",
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
},
],
})
expect(result.success).toBe(true)
})
it('rejects an unknown cell _tag', () => {
const result = writableNotebookSchema.safeParse({
schema_version: 1,
cells: [{ _tag: 'chart_cell', text: 'hi' }],
})
expect(result.success).toBe(false)
})
it('rejects a database_cell missing row_limit', () => {
const result = writableNotebookSchema.safeParse({
schema_version: 1,
cells: [{ _tag: 'database_cell', sql: 'select 1' }],
})
expect(result.success).toBe(false)
})
it('rejects an invalid relative_time_range unit', () => {
const result = writableNotebookSchema.safeParse({
schema_version: 1,
cells: [
{
_tag: 'log_cell',
sql: 'select 1',
time_range: { _tag: 'relative_time_range', unit: 'fortnight', amount: 1 },
},
],
})
expect(result.success).toBe(false)
})
})
describe('notebookDomainSchema', () => {
it('brands database_cell and log_cell sql as unchecked_sql, leaving markdown_cell untouched', () => {
const result = notebookDomainSchema.safeParse(FULL_NOTEBOOK)
@@ -1,7 +1,7 @@
import { untrustedSql } from '@supabase/pg-meta'
import { untrustedSql, type SafeSqlFragment } from '@supabase/pg-meta'
import * as z from 'zod'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
import { untrustedLogSql, type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql'
import { isoDateTimeString } from '@/lib/iso-datetime'
const isoDateTimeSchema = z.string().transform((raw, ctx) => {
@@ -74,6 +74,36 @@ export const notebookSchema = z.object({
export type NotebookWire = z.infer<typeof notebookSchema>
export type CellWire = z.infer<typeof cellSchema>
// Cells for the create/update PUT body (data/content/notebooks/notebook-upsert-mutation.ts).
// An existing cell being kept or edited carries its real backend-assigned `id` so the
// backend can diff it against the previous version; a newly inserted cell has no `id` at
// all — the backend generates one on write. `sql` must already be a SafeSqlFragment /
// SafeLogSqlFragment — i.e. promoted at the point of user action per the
// safe-sql-execution skill — never a raw string or an UntrustedSqlFragment/unchecked_sql,
// since neither proves this specific save was user-authored.
const writableCellSchema = z.discriminatedUnion('_tag', [
markdownCellSchema.extend({ id: z.string().optional() }),
databaseCellSchema.extend({ id: z.string().optional() }),
logCellSchema.extend({ id: z.string().optional() }),
])
export const writableNotebookSchema = z.object({
schema_version: z.literal(1),
cells: z.array(writableCellSchema),
})
type WithSafeSql<C extends { _tag: string }> = C extends { _tag: 'database_cell' }
? Omit<C, 'sql'> & { sql: SafeSqlFragment }
: C extends { _tag: 'log_cell' }
? Omit<C, 'sql'> & { sql: SafeLogSqlFragment }
: C
export type WritableCell = WithSafeSql<z.infer<typeof writableCellSchema>>
export type WritableNotebook = Omit<z.infer<typeof writableNotebookSchema>, 'cells'> & {
cells: Array<WritableCell>
}
// Agents have restrictions on writing IDs to preserve guarantees about ID
// uniqueness.
export const agentCellSchema = z.discriminatedUnion('_tag', [
@@ -0,0 +1,144 @@
import { safeSql } from '@supabase/pg-meta'
import { HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import type { WritableNotebook } from './notebook-schema'
import { createNotebook, updateNotebook } from './notebook-upsert-mutation'
import { safeSql as safeLogSql } from '@/data/logs/safe-analytics-sql'
import { addAPIMock } from '@/tests/lib/msw'
const DATABASE_SQL = safeSql`select * from auth.users limit 100`
const LOG_SQL = "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10"
const EXISTING_CELL_ID = 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22'
// Create-shaped: no cell carries an `id` — the notebook is brand new, so no cell has a
// prior version for the backend to diff against; ids are backend-generated on write.
const VALID_CONTENT: WritableNotebook = {
schema_version: 1,
cells: [
{ _tag: 'markdown_cell', text: '# Signup funnel' },
{ _tag: 'database_cell', sql: DATABASE_SQL, row_limit: 100 },
{
_tag: 'log_cell',
sql: safeLogSql`select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10`,
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
},
],
}
// Update-shaped: mixes an existing cell (kept, carries its real backend-assigned id so the
// backend can diff it against the previous version) with a newly inserted cell (no id —
// same as create, the backend assigns one on write).
const UPDATE_CONTENT: WritableNotebook = {
schema_version: 1,
cells: [
{
_tag: 'database_cell',
id: EXISTING_CELL_ID,
sql: DATABASE_SQL,
row_limit: 100,
},
{
_tag: 'log_cell',
sql: safeLogSql`select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10`,
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
},
],
}
// Missing `row_limit` on the database_cell — invalid per writableNotebookSchema. The
// point of this fixture is to prove validation runs (and rejects) before any request is
// sent, so it's cast rather than satisfying the real type.
const INVALID_CONTENT = {
schema_version: 1,
cells: [
{
_tag: 'database_cell',
sql: safeSql`select 1`,
},
],
} as unknown as WritableNotebook
describe('createNotebook', () => {
it('PUTs with type notebook, visibility project, a freshly generated id, and cells with no id', async () => {
let sentBody: Record<string, unknown> | undefined
addAPIMock({
method: 'put',
path: '/platform/projects/:ref/content',
response: async ({ request }) => {
sentBody = (await request.json()) as Record<string, unknown>
return HttpResponse.json(null)
},
})
await createNotebook({
projectRef: 'default',
name: 'Signup funnel',
content: VALID_CONTENT,
})
expect(sentBody?.type).toBe('notebook')
expect(sentBody?.visibility).toBe('project')
expect(typeof sentBody?.id).toBe('string')
const content = sentBody?.content as WritableNotebook
for (const cell of content.cells) {
expect(cell).not.toHaveProperty('id')
}
const [, databaseCell, logCell] = content.cells as Array<Record<string, unknown>>
expect(databaseCell.sql).toBe(DATABASE_SQL)
expect(logCell.sql).toBe(LOG_SQL)
})
it('rejects malformed content without making a network request', async () => {
await expect(
createNotebook({ projectRef: 'default', name: 'Bad notebook', content: INVALID_CONTENT })
).rejects.toThrow()
})
})
describe('updateNotebook', () => {
const NOTEBOOK_ID = 'd3aadd77-7c3c-4de7-aa5c-5aa8ac270b44'
it('PUTs with the given id, keeping the existing cell id and leaving the new cell without one', async () => {
let sentBody: Record<string, unknown> | undefined
addAPIMock({
method: 'put',
path: '/platform/projects/:ref/content',
response: async ({ request }) => {
sentBody = (await request.json()) as Record<string, unknown>
return HttpResponse.json(null)
},
})
await updateNotebook({
projectRef: 'default',
id: NOTEBOOK_ID,
name: 'Signup funnel',
content: UPDATE_CONTENT,
})
expect(sentBody?.id).toBe(NOTEBOOK_ID)
expect(sentBody?.type).toBe('notebook')
const content = sentBody?.content as WritableNotebook
const [databaseCell, logCell] = content.cells as Array<Record<string, unknown>>
expect(databaseCell.id).toBe(EXISTING_CELL_ID)
expect(databaseCell.sql).toBe(DATABASE_SQL)
expect(logCell).not.toHaveProperty('id')
expect(logCell.sql).toBe(LOG_SQL)
})
it('rejects malformed content without making a network request', async () => {
await expect(
updateNotebook({
projectRef: 'default',
id: NOTEBOOK_ID,
name: 'Bad notebook',
content: INVALID_CONTENT,
})
).rejects.toThrow()
})
})
@@ -0,0 +1,138 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { upsertContent, type UpsertContentPayload } from '../content-upsert-mutation'
import { contentKeys } from '../keys'
import { writableNotebookSchema, type WritableNotebook } from './notebook-schema'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
// TODO — Charis 2026-08-06
// UpsertContentPayload['type'] (generated from api-types) doesn't have 'notebook' yet — same
// gap tracked by the ContentBase TODO in content-query.ts. Widen locally and cast at the
// upsertContent call site until the generated type picks it up.
type NotebookUpsertPayload = Omit<UpsertContentPayload, 'type' | 'content'> & {
type: 'notebook'
content: WritableNotebook
}
function buildNotebookUpsertPayload({
id,
name,
description,
content,
}: {
id: string
name: string
description?: string
content: WritableNotebook
}): NotebookUpsertPayload {
writableNotebookSchema.parse(content)
return {
id,
name,
description,
type: 'notebook',
visibility: 'project',
content,
}
}
export type CreateNotebookVariables = {
projectRef: string
name: string
description?: string
content: WritableNotebook
}
export async function createNotebook(
{ projectRef, name, description, content }: CreateNotebookVariables,
signal?: AbortSignal
) {
const payload = buildNotebookUpsertPayload({
id: crypto.randomUUID(),
name,
description,
content,
})
return upsertContent({ projectRef, payload: payload as unknown as UpsertContentPayload }, signal)
}
export type CreateNotebookData = Awaited<ReturnType<typeof createNotebook>>
export type UpdateNotebookVariables = CreateNotebookVariables & { id: string }
export async function updateNotebook(
{ projectRef, id, name, description, content }: UpdateNotebookVariables,
signal?: AbortSignal
) {
const payload = buildNotebookUpsertPayload({ id, name, description, content })
return upsertContent({ projectRef, payload: payload as unknown as UpsertContentPayload }, signal)
}
export type UpdateNotebookData = Awaited<ReturnType<typeof updateNotebook>>
export const useCreateNotebookMutation = ({
onError,
onSuccess,
...options
}: Omit<
UseCustomMutationOptions<CreateNotebookData, ResponseError, CreateNotebookVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<CreateNotebookData, ResponseError, CreateNotebookVariables>({
mutationFn: (args) => createNotebook(args),
async onSuccess(data, variables, context) {
const { projectRef } = variables
await Promise.all([
queryClient.invalidateQueries({ queryKey: contentKeys.allContentLists(projectRef) }),
queryClient.invalidateQueries({ queryKey: contentKeys.infiniteList(projectRef) }),
])
await onSuccess?.(data, variables, context)
},
async onError(error, variables, context) {
if (onError === undefined) {
toast.error(`Failed to create notebook: ${error.message}`)
} else {
onError(error, variables, context)
}
},
...options,
})
}
export const useUpdateNotebookMutation = ({
onError,
onSuccess,
...options
}: Omit<
UseCustomMutationOptions<UpdateNotebookData, ResponseError, UpdateNotebookVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<UpdateNotebookData, ResponseError, UpdateNotebookVariables>({
mutationFn: (args) => updateNotebook(args),
async onSuccess(data, variables, context) {
const { projectRef, id } = variables
await Promise.all([
queryClient.invalidateQueries({ queryKey: contentKeys.allContentLists(projectRef) }),
queryClient.invalidateQueries({ queryKey: contentKeys.infiniteList(projectRef) }),
queryClient.invalidateQueries({ queryKey: contentKeys.resource(projectRef, id) }),
])
await onSuccess?.(data, variables, context)
},
async onError(error, variables, context) {
if (onError === undefined) {
toast.error(`Failed to update notebook: ${error.message}`)
} else {
onError(error, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,50 @@
import { waitFor } from '@testing-library/react'
import { HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { useNotebooksInfiniteQuery } from './notebooks-infinite-query'
import type { components } from '@/data/api'
import { customRenderHook } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
const NOTEBOOK_ROW = {
id: 'd3aadd77-7c3c-4de7-aa5c-5aa8ac270b44',
type: 'notebook',
name: 'Signup funnel',
description: '',
favorite: false,
folder_id: null,
inserted_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
visibility: 'project',
owner_id: 1,
project_id: 1,
owner: { id: 1, username: 'test' },
updated_by: { id: 1, username: 'test' },
content: {
schema_version: 1,
cells: [],
},
}
describe('useNotebooksInfiniteQuery', () => {
it('narrows the page content to notebook rows', async () => {
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/content',
response: () =>
HttpResponse.json<components['schemas']['GetUserContentResponse']>({
cursor: undefined,
data: [NOTEBOOK_ROW],
} as unknown as components['schemas']['GetUserContentResponse']),
})
const { result } = customRenderHook(() => useNotebooksInfiniteQuery({ projectRef: 'default' }))
await waitFor(() => expect(result.current.data).toBeDefined())
expect(result.current.data?.pages[0].content).toEqual([
{ ...NOTEBOOK_ROW, content: { schema_version: 1, cells: [] } },
])
})
})
@@ -0,0 +1,47 @@
import type { InfiniteData } from '@tanstack/react-query'
import {
useContentInfiniteQuery,
type ContentData,
type ContentError,
} from '../content-infinite-query'
import type { ContentOfType } from '../content-query'
import type { UseCustomInfiniteQueryOptions } from '@/types'
export type NotebookRow = ContentOfType<'notebook'>
export interface NotebooksVariables {
projectRef?: string
name?: string
limit?: number
sort?: 'name' | 'inserted_at'
}
export interface NotebooksPage {
cursor: string | undefined
content: NotebookRow[]
}
export const useNotebooksInfiniteQuery = (
{ projectRef, name, limit, sort }: NotebooksVariables,
options: Omit<
UseCustomInfiniteQueryOptions<
ContentData,
ContentError,
InfiniteData<NotebooksPage>,
ReadonlyArray<unknown>,
string | undefined
>,
'select'
> = {}
) =>
useContentInfiniteQuery<NotebooksPage>(
{ projectRef, type: 'notebook', name, limit, sort },
{
...options,
select: (data) => ({
...data,
pages: data.pages.map((page) => ({ ...page, content: page.content as NotebookRow[] })),
}),
}
)