diff --git a/apps/studio/data/content/content-remap.test.ts b/apps/studio/data/content/content-remap.test.ts index e9ffd9c899b..1c288ea7810 100644 --- a/apps/studio/data/content/content-remap.test.ts +++ b/apps/studio/data/content/content-remap.test.ts @@ -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) - }) }) diff --git a/apps/studio/data/content/content-remap.ts b/apps/studio/data/content/content-remap.ts index 2dfed9dd64f..a01b09d7e3b 100644 --- a/apps/studio/data/content/content-remap.ts +++ b/apps/studio/data/content/content-remap.ts @@ -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(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(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 diff --git a/apps/studio/data/content/notebooks/notebook-query.test.ts b/apps/studio/data/content/notebooks/notebook-query.test.ts new file mode 100644 index 00000000000..cabd96fb6d6 --- /dev/null +++ b/apps/studio/data/content/notebooks/notebook-query.test.ts @@ -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( + 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({ + 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/ + ) + }) +}) diff --git a/apps/studio/data/content/notebooks/notebook-query.ts b/apps/studio/data/content/notebooks/notebook-query.ts new file mode 100644 index 00000000000..66d62839cca --- /dev/null +++ b/apps/studio/data/content/notebooks/notebook-query.ts @@ -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 & { + type: 'notebook' + content: Notebooks.Content + } +} + +export type NotebookData = Awaited> + +export const useNotebookQuery = ( + { projectRef, id }: NotebookVariables, + { enabled = true, ...options }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: contentKeys.resource(projectRef, id), + queryFn: ({ signal }) => getNotebook({ projectRef, id }, signal), + enabled: enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined', + ...options, + }) diff --git a/apps/studio/data/content/notebooks/notebook-schema.test.ts b/apps/studio/data/content/notebooks/notebook-schema.test.ts index 8f81fff51cf..f50bd20c9a6 100644 --- a/apps/studio/data/content/notebooks/notebook-schema.test.ts +++ b/apps/studio/data/content/notebooks/notebook-schema.test.ts @@ -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) diff --git a/apps/studio/data/content/notebooks/notebook-schema.ts b/apps/studio/data/content/notebooks/notebook-schema.ts index e5ca0c796da..3d554b8d5a8 100644 --- a/apps/studio/data/content/notebooks/notebook-schema.ts +++ b/apps/studio/data/content/notebooks/notebook-schema.ts @@ -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 export type CellWire = z.infer +// 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: 'database_cell' } + ? Omit & { sql: SafeSqlFragment } + : C extends { _tag: 'log_cell' } + ? Omit & { sql: SafeLogSqlFragment } + : C + +export type WritableCell = WithSafeSql> + +export type WritableNotebook = Omit, 'cells'> & { + cells: Array +} + // Agents have restrictions on writing IDs to preserve guarantees about ID // uniqueness. export const agentCellSchema = z.discriminatedUnion('_tag', [ diff --git a/apps/studio/data/content/notebooks/notebook-upsert-mutation.test.ts b/apps/studio/data/content/notebooks/notebook-upsert-mutation.test.ts new file mode 100644 index 00000000000..c32ff0df315 --- /dev/null +++ b/apps/studio/data/content/notebooks/notebook-upsert-mutation.test.ts @@ -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 | undefined + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: async ({ request }) => { + sentBody = (await request.json()) as Record + 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> + 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 | undefined + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: async ({ request }) => { + sentBody = (await request.json()) as Record + 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> + 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() + }) +}) diff --git a/apps/studio/data/content/notebooks/notebook-upsert-mutation.ts b/apps/studio/data/content/notebooks/notebook-upsert-mutation.ts new file mode 100644 index 00000000000..f585dd4e4a5 --- /dev/null +++ b/apps/studio/data/content/notebooks/notebook-upsert-mutation.ts @@ -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 & { + 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> + +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> + +export const useCreateNotebookMutation = ({ + onError, + onSuccess, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + 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, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + 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, + }) +} diff --git a/apps/studio/data/content/notebooks/notebooks-infinite-query.test.ts b/apps/studio/data/content/notebooks/notebooks-infinite-query.test.ts new file mode 100644 index 00000000000..e9a54dc3435 --- /dev/null +++ b/apps/studio/data/content/notebooks/notebooks-infinite-query.test.ts @@ -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({ + 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: [] } }, + ]) + }) +}) diff --git a/apps/studio/data/content/notebooks/notebooks-infinite-query.ts b/apps/studio/data/content/notebooks/notebooks-infinite-query.ts new file mode 100644 index 00000000000..8e7db98d784 --- /dev/null +++ b/apps/studio/data/content/notebooks/notebooks-infinite-query.ts @@ -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, + ReadonlyArray, + string | undefined + >, + 'select' + > = {} +) => + useContentInfiniteQuery( + { projectRef, type: 'notebook', name, limit, sort }, + { + ...options, + select: (data) => ({ + ...data, + pages: data.pages.map((page) => ({ ...page, content: page.content as NotebookRow[] })), + }), + } + )