mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## Context Changes here adds a "Draft" state for notebooks with a new `notebook-drafts` store - similar to how we handle query tabs in the explorer. This implies that if a user refreshes the tab while there's unsaved changes to notebooks, the changes can be persisted locally and the user will be able to continue from where they left off. This also implies that If you create a new notebook (OR open an existing notebook and make some changes) and refresh the browser, we no longer show the native browser confirmation dialog about discarding changes. We also reuse the existing confirmation dialog when saving a notebook if its draft has diverged from the server side content - just updated the language to be more generic rather than saying that the Assistant made changes <img width="429" height="238" alt="image" src="https://github.com/user-attachments/assets/5c392aed-1633-4428-8060-28f495a01f04" /> Also fixes an unrelated issue - renaming a notebook should mark the notebook as having unsaved changes (with the orange indicator) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Unsaved notebook edits are saved locally and restored when reopening Studio. * Drafts are scoped by project and protected from server changes through conflict detection. * Notebook tabs indicate unsaved changes, including drafts from unsaved notebooks. * **Bug Fixes** * Closing a tab with local edits prompts for confirmation and removes its saved draft. * Notebook save state reflects the server-confirmed update time. * Conflict messages clearly describe changes made on the server. * **Style** * Improved keyboard focus behavior for tab controls. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
259 lines
9.5 KiB
TypeScript
259 lines
9.5 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import { persistNotebookDraft, readNotebookDraft } from './notebook-drafts'
|
|
import { notebooksState } from './notebooks-state'
|
|
import type { Notebook } from './types'
|
|
import type { Notebooks } from '@/types'
|
|
|
|
function makeNotebook(id: string, overrides: Partial<Notebook> = {}): Notebook {
|
|
return {
|
|
id,
|
|
type: 'notebook',
|
|
name: 'My Notebook',
|
|
description: '',
|
|
visibility: 'project',
|
|
favorite: false,
|
|
owner_id: 7,
|
|
project_id: 42,
|
|
content: { schema_version: 1, cells: [] },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('notebooksState', () => {
|
|
beforeEach(() => {
|
|
// notebooksState is a module-level singleton, so reset the state these tests touch
|
|
for (const id of Object.keys(notebooksState.notebooks)) {
|
|
delete notebooksState.notebooks[id]
|
|
}
|
|
notebooksState.needsSaving.clear()
|
|
notebooksState.cellLocalState.clear()
|
|
notebooksState.serverDivergedWhileDirty.clear()
|
|
localStorage.clear()
|
|
})
|
|
|
|
it('addNotebook marks a locally-created notebook as new', () => {
|
|
notebooksState.addNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
|
|
|
expect(notebooksState.notebooks['notebook-1'].status).toBe('new')
|
|
})
|
|
|
|
it('addNotebook persists a local draft immediately, even for a still-empty notebook', () => {
|
|
notebooksState.addNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
|
|
|
const draft = readNotebookDraft({ projectRef: 'ref', id: 'notebook-1' })
|
|
expect(draft?.name).toBe('My Notebook')
|
|
expect(draft?.content.cells).toEqual([])
|
|
})
|
|
|
|
it('setNotebook marks a notebook not yet in the store as saved', () => {
|
|
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
|
|
|
expect(notebooksState.notebooks['notebook-1'].status).toBe('saved')
|
|
})
|
|
|
|
it('keeps query visibility in session state without marking the notebook as edited', () => {
|
|
const queryCell = {
|
|
_tag: 'database_cell' as const,
|
|
_id: 'cell-1',
|
|
unchecked_sql: '' as Notebooks.DatabaseCell['unchecked_sql'],
|
|
row_limit: 100,
|
|
view: 'table' as const,
|
|
}
|
|
notebooksState.setNotebook({
|
|
projectRef: 'ref',
|
|
notebook: makeNotebook('notebook-1', {
|
|
content: { schema_version: 1, cells: [queryCell] },
|
|
}),
|
|
})
|
|
|
|
expect(notebooksState.cellLocalState.has('cell-1')).toBe(false)
|
|
|
|
notebooksState.setQueryVisibility({ cellId: 'cell-1', showQuery: true })
|
|
|
|
expect(notebooksState.cellLocalState.get('cell-1')).toEqual({ showQuery: true })
|
|
expect(notebooksState.notebooks['notebook-1'].status).toBe('saved')
|
|
expect(notebooksState.needsSaving.has('notebook-1')).toBe(false)
|
|
})
|
|
|
|
it('editing a loaded (saved) notebook transitions it to unsaved and queues it for saving', () => {
|
|
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
|
|
|
notebooksState.updateCells({
|
|
id: 'notebook-1',
|
|
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'hello' }],
|
|
})
|
|
|
|
expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved')
|
|
expect(notebooksState.needsSaving.get('notebook-1')).toBe(false)
|
|
})
|
|
|
|
it('renaming a loaded (saved) notebook transitions it to unsaved and persists a draft', () => {
|
|
notebooksState.setNotebook({
|
|
projectRef: 'ref',
|
|
notebook: makeNotebook('notebook-1', { updated_at: '2024-01-01T00:00:00.000Z' }),
|
|
})
|
|
|
|
notebooksState.renameNotebook({ id: 'notebook-1', name: 'Renamed notebook' })
|
|
|
|
expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved')
|
|
expect(notebooksState.notebooks['notebook-1'].notebook.name).toBe('Renamed notebook')
|
|
|
|
const draft = readNotebookDraft({ projectRef: 'ref', id: 'notebook-1' })
|
|
expect(draft?.name).toBe('Renamed notebook')
|
|
expect(draft?.baseUpdatedAt).toBe('2024-01-01T00:00:00.000Z')
|
|
})
|
|
|
|
it('editing a notebook that has never been saved keeps it as new', () => {
|
|
notebooksState.addNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
|
|
|
notebooksState.updateCells({
|
|
id: 'notebook-1',
|
|
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'hello' }],
|
|
})
|
|
|
|
expect(notebooksState.notebooks['notebook-1'].status).toBe('new')
|
|
})
|
|
|
|
it('setNotebook does not downgrade an already-loaded notebook back to saved after edits', () => {
|
|
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
|
notebooksState.updateCells({
|
|
id: 'notebook-1',
|
|
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'hello' }],
|
|
})
|
|
expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved')
|
|
|
|
// Re-fetching/merging content for the same notebook (e.g. a second setNotebook
|
|
// call) must not reset its status back to 'saved' while edits are pending.
|
|
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
|
|
|
expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved')
|
|
})
|
|
|
|
it('marks and explicitly clears server divergence', () => {
|
|
notebooksState.markServerDivergence({ id: 'notebook-1', type: 'updated' })
|
|
|
|
expect(notebooksState.serverDivergedWhileDirty.get('notebook-1')).toBe('updated')
|
|
|
|
notebooksState.clearServerDivergence({ id: 'notebook-1' })
|
|
|
|
expect(notebooksState.serverDivergedWhileDirty.has('notebook-1')).toBe(false)
|
|
})
|
|
|
|
it('clears server divergence after saving a notebook', () => {
|
|
notebooksState.addNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
|
notebooksState.markServerDivergence({ id: 'notebook-1', type: 'updated' })
|
|
|
|
notebooksState.markSaved({ id: 'notebook-1' })
|
|
|
|
expect(notebooksState.notebooks['notebook-1'].status).toBe('saved')
|
|
expect(notebooksState.serverDivergedWhileDirty.has('notebook-1')).toBe(false)
|
|
})
|
|
|
|
it('clears server divergence when removing a notebook', () => {
|
|
notebooksState.addNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
|
notebooksState.markServerDivergence({ id: 'notebook-1', type: 'deleted' })
|
|
|
|
notebooksState.removeNotebook({ id: 'notebook-1' })
|
|
|
|
expect(notebooksState.notebooks['notebook-1']).toBeUndefined()
|
|
expect(notebooksState.serverDivergedWhileDirty.has('notebook-1')).toBe(false)
|
|
})
|
|
|
|
it('persists a local draft of every cell edit', () => {
|
|
notebooksState.setNotebook({
|
|
projectRef: 'ref',
|
|
notebook: makeNotebook('notebook-1', { updated_at: '2024-01-01T00:00:00.000Z' }),
|
|
})
|
|
|
|
notebooksState.updateCells({
|
|
id: 'notebook-1',
|
|
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'hello' }],
|
|
})
|
|
|
|
const draft = readNotebookDraft({ projectRef: 'ref', id: 'notebook-1' })
|
|
expect(draft?.content.cells).toMatchObject([{ _tag: 'markdown_cell', text: 'hello' }])
|
|
expect(draft?.baseUpdatedAt).toBe('2024-01-01T00:00:00.000Z')
|
|
})
|
|
|
|
it('clears a notebook local draft once it is saved', () => {
|
|
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
|
notebooksState.updateCells({
|
|
id: 'notebook-1',
|
|
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'hello' }],
|
|
})
|
|
expect(readNotebookDraft({ projectRef: 'ref', id: 'notebook-1' })).toBeDefined()
|
|
|
|
notebooksState.markSaved({ id: 'notebook-1', updatedAt: '2024-02-02T00:00:00.000Z' })
|
|
|
|
expect(readNotebookDraft({ projectRef: 'ref', id: 'notebook-1' })).toBeUndefined()
|
|
expect(notebooksState.notebooks['notebook-1'].notebook.updated_at).toBe(
|
|
'2024-02-02T00:00:00.000Z'
|
|
)
|
|
})
|
|
|
|
it('clears a notebook local draft when the notebook is removed', () => {
|
|
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
|
notebooksState.updateCells({
|
|
id: 'notebook-1',
|
|
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'hello' }],
|
|
})
|
|
|
|
notebooksState.removeNotebook({ id: 'notebook-1' })
|
|
|
|
expect(readNotebookDraft({ projectRef: 'ref', id: 'notebook-1' })).toBeUndefined()
|
|
})
|
|
|
|
it('restores a local draft onto a freshly-loaded notebook', () => {
|
|
persistNotebookDraft({
|
|
projectRef: 'ref',
|
|
id: 'notebook-1',
|
|
name: 'Restored name',
|
|
content: {
|
|
schema_version: 1,
|
|
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'draft' }],
|
|
},
|
|
baseUpdatedAt: '2024-01-01T00:00:00.000Z',
|
|
})
|
|
notebooksState.setNotebook({
|
|
projectRef: 'ref',
|
|
notebook: makeNotebook('notebook-1', { updated_at: '2024-01-01T00:00:00.000Z' }),
|
|
})
|
|
|
|
notebooksState.restoreDraft({
|
|
projectRef: 'ref',
|
|
id: 'notebook-1',
|
|
baseUpdatedAt: '2024-01-01T00:00:00.000Z',
|
|
})
|
|
|
|
expect(notebooksState.notebooks['notebook-1'].notebook.name).toBe('Restored name')
|
|
expect(notebooksState.notebooks['notebook-1'].notebook.content?.cells).toMatchObject([
|
|
{ _tag: 'markdown_cell', text: 'draft' },
|
|
])
|
|
expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved')
|
|
expect(notebooksState.serverDivergedWhileDirty.has('notebook-1')).toBe(false)
|
|
})
|
|
|
|
it('flags a server-diverged conflict when the draft branched from a stale server version', () => {
|
|
persistNotebookDraft({
|
|
projectRef: 'ref',
|
|
id: 'notebook-1',
|
|
name: 'Restored name',
|
|
content: { schema_version: 1, cells: [] },
|
|
baseUpdatedAt: '2024-01-01T00:00:00.000Z',
|
|
})
|
|
notebooksState.setNotebook({
|
|
projectRef: 'ref',
|
|
notebook: makeNotebook('notebook-1', { updated_at: '2024-06-01T00:00:00.000Z' }),
|
|
})
|
|
|
|
notebooksState.restoreDraft({
|
|
projectRef: 'ref',
|
|
id: 'notebook-1',
|
|
baseUpdatedAt: '2024-06-01T00:00:00.000Z',
|
|
})
|
|
|
|
expect(notebooksState.serverDivergedWhileDirty.get('notebook-1')).toBe('updated')
|
|
})
|
|
})
|