mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
0111aa371b
commit
1296a1c745
10 files changed
+644
-32
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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[] })),
|
||||
}),
|
||||
}
|
||||
)
|
||||
Reference in new issue
Block a user