mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 18:11:51 +08:00
## Summary Fixed a bug in the AI Assistant notebook-update proposal preview where a `replace_cell` operation that only changed a cell's view (table ↔ chart) or chart parameters (type, x/y columns, cumulative, scale, labels) would show as a "Replaced" row but the expanded diff would appear empty. **Root cause:** The diff editor only compared the cell's SQL text; view and chart configuration were never considered, so changes to those aspects showed no diff. **Solution:** * Refactored `getCellMetadata` to return structured `NotebookCellFields` with separate `source` (database/time range) and `view` (table/chart) fields instead of a single concatenated string * Added `formatChartConfig` and `formatCellView` helpers to describe chart cells * Updated `getEntryMetadata` to diff source and view independently, showing only the fields that actually changed (e.g., "Table → Chart (bar, ...)" when only the view changed, with the unchanged database omitted) * If neither field changed, metadata is hidden entirely ## Test plan * Added test cases for: chart-view cells reporting a `view` field, view-only changes surfacing without the unchanged database, chart-parameter-only changes surfacing without the unchanged database, database-only changes surfacing without the unchanged view, and fully-unchanged replacements hiding metadata entirely * All 43 tests in the touched test file pass * `tsc --noEmit` on apps/studio shows no new type errors ## Summary by CodeRabbit * **Enhancements** * Improved AI Assistant notebook previews with clearer cell details, including source content and table or chart views. * Chart previews now show key configuration details, such as chart type and selected dimensions * Replacement previews highlight only the fields that changed and hide entries with no visible changes. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Notebook previews now distinguish cell content from its view, including table and chart details. * Chart previews display relevant configuration, such as chart type and axes. * Log previews include their formatted time range. * Replacement previews now show only the fields that changed. * **Bug Fixes** * Unchanged replacements are now hidden instead of displaying misleading content. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
475 lines
14 KiB
TypeScript
475 lines
14 KiB
TypeScript
import dayjs from 'dayjs'
|
||
import { describe, expect, it } from 'vitest'
|
||
|
||
import {
|
||
formatNotebookDiffSummary,
|
||
formatTimeRange,
|
||
getCellLabel,
|
||
getCellMetadata,
|
||
getEntryKey,
|
||
getEntryMetadata,
|
||
notebookEntriesNeedDatabaseLookup,
|
||
resolveNotebookDatabaseTarget,
|
||
summarizeNotebookDiff,
|
||
type NotebookDatabaseContext,
|
||
} from './AssistantNotebookPreview.utils'
|
||
import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations'
|
||
import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-schema'
|
||
import { isoDateTimeString } from '@/lib/iso-datetime'
|
||
|
||
const wireMarkdownCell = (id: string, text = 'hello'): CellWire => ({
|
||
_tag: 'markdown_cell',
|
||
_id: id,
|
||
text,
|
||
})
|
||
|
||
const agentMarkdownCell = (text = 'hello'): AgentCell => ({ _tag: 'markdown_cell', text })
|
||
|
||
const wireDatabaseCell = (id: string, title?: string, database_identifier?: string): CellWire => ({
|
||
_tag: 'database_cell',
|
||
_id: id,
|
||
title,
|
||
sql: 'select 1',
|
||
row_limit: 100,
|
||
database_identifier,
|
||
})
|
||
|
||
const wireLogCell = (id: string): CellWire => ({
|
||
_tag: 'log_cell',
|
||
_id: id,
|
||
sql: 'select 1',
|
||
time_range: { _tag: 'relative_time_range', unit: 'day', amount: 7 },
|
||
})
|
||
|
||
const agentDatabaseCell = (database_identifier?: string): AgentCell => ({
|
||
_tag: 'database_cell',
|
||
sql: 'select 1',
|
||
row_limit: 100,
|
||
database_identifier,
|
||
})
|
||
|
||
const chartDatabaseCell = (id: string, ySeries: string[]): CellWire => ({
|
||
_tag: 'database_cell',
|
||
_id: id,
|
||
sql: 'select 1',
|
||
row_limit: 100,
|
||
view: 'chart',
|
||
chart: {
|
||
type: 'bar',
|
||
x_column: 'day',
|
||
y_series: ySeries,
|
||
cumulative: false,
|
||
scale: 'linear',
|
||
show_labels: false,
|
||
},
|
||
})
|
||
|
||
const agentChartDatabaseCell = (ySeries: string[], database_identifier?: string): AgentCell => ({
|
||
_tag: 'database_cell',
|
||
sql: 'select 1',
|
||
row_limit: 100,
|
||
database_identifier,
|
||
view: 'chart',
|
||
chart: {
|
||
type: 'bar',
|
||
x_column: 'day',
|
||
y_series: ySeries,
|
||
cumulative: false,
|
||
scale: 'linear',
|
||
show_labels: false,
|
||
},
|
||
})
|
||
|
||
const successfulDatabaseContext = (
|
||
databases: Array<{ identifier: string; region: string }> = []
|
||
): NotebookDatabaseContext => ({
|
||
status: 'success',
|
||
projectRef: 'project-1',
|
||
databasesByIdentifier: new Map(databases.map((database) => [database.identifier, database])),
|
||
})
|
||
|
||
describe('getEntryKey', () => {
|
||
it('keys unchanged, removed, and moved entries off the cell id', () => {
|
||
expect(getEntryKey({ _tag: 'unchanged', cell: wireMarkdownCell('cell-1') })).toBe('cell-1')
|
||
expect(
|
||
getEntryKey({ _tag: 'removed', cell: wireMarkdownCell('cell-2'), operationIndex: 0 })
|
||
).toBe('cell-2')
|
||
expect(
|
||
getEntryKey({
|
||
_tag: 'moved',
|
||
cell: wireMarkdownCell('cell-3'),
|
||
fromIndex: 1,
|
||
operationIndex: 0,
|
||
})
|
||
).toBe('cell-3')
|
||
})
|
||
|
||
it('keys added and replaced entries off the operation index', () => {
|
||
expect(getEntryKey({ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 2 })).toBe(
|
||
'op-2'
|
||
)
|
||
expect(
|
||
getEntryKey({
|
||
_tag: 'replaced',
|
||
before: wireMarkdownCell('cell-4'),
|
||
after: agentMarkdownCell(),
|
||
operationIndex: 3,
|
||
})
|
||
).toBe('op-3')
|
||
})
|
||
})
|
||
|
||
describe('getCellLabel', () => {
|
||
it('labels markdown cells', () => {
|
||
expect(getCellLabel(wireMarkdownCell('cell-1'))).toBe('Markdown cell')
|
||
})
|
||
|
||
it('labels query cells with their title', () => {
|
||
expect(getCellLabel(wireDatabaseCell('cell-1', 'Signups'))).toBe('Query: Signups')
|
||
})
|
||
|
||
it('falls back to "Untitled query" when a query cell has no title', () => {
|
||
expect(getCellLabel(wireDatabaseCell('cell-1'))).toBe('Query: Untitled query')
|
||
})
|
||
})
|
||
|
||
describe('formatTimeRange', () => {
|
||
it('formats a relative range, pluralizing the unit', () => {
|
||
expect(formatTimeRange({ _tag: 'relative_time_range', unit: 'day', amount: 7 })).toBe(
|
||
'Last 7 days'
|
||
)
|
||
})
|
||
|
||
it('does not pluralize a relative range with amount 1', () => {
|
||
expect(formatTimeRange({ _tag: 'relative_time_range', unit: 'hour', amount: 1 })).toBe(
|
||
'Last 1 hour'
|
||
)
|
||
})
|
||
|
||
it('formats an absolute range as a start → end pair', () => {
|
||
const start = isoDateTimeString('2026-01-01T13:00:00.000Z')!
|
||
const end = isoDateTimeString('2026-01-02T09:30:00.000Z')!
|
||
|
||
const formatted = formatTimeRange({ _tag: 'absolute_time_range', start, end })
|
||
|
||
// Bounds are asserted via dayjs rather than a hardcoded string so this doesn't depend on
|
||
// the test runner's local timezone (formatTimeRange formats in local time).
|
||
const expectedBound = (value: string) => dayjs(value).format('MMM D, YYYY h:mm A')
|
||
expect(formatted).toBe(`${expectedBound(start)} → ${expectedBound(end)}`)
|
||
})
|
||
})
|
||
|
||
describe('resolveNotebookDatabaseTarget', () => {
|
||
it('resolves an omitted identifier as primary without waiting for databases', () => {
|
||
expect(
|
||
resolveNotebookDatabaseTarget(undefined, { status: 'loading', projectRef: 'project-1' })
|
||
).toEqual({ status: 'primary' })
|
||
})
|
||
|
||
it('resolves the project ref as primary without waiting for databases', () => {
|
||
expect(
|
||
resolveNotebookDatabaseTarget('project-1', {
|
||
status: 'loading',
|
||
projectRef: 'project-1',
|
||
})
|
||
).toEqual({ status: 'primary' })
|
||
})
|
||
|
||
it('still resolves the loaded database matching the project ref as primary', () => {
|
||
expect(
|
||
resolveNotebookDatabaseTarget(
|
||
'project-1',
|
||
successfulDatabaseContext([{ identifier: 'project-1', region: 'us-east-1' }])
|
||
)
|
||
).toEqual({ status: 'primary' })
|
||
})
|
||
|
||
it('keeps another identifier loading until databases resolve', () => {
|
||
expect(
|
||
resolveNotebookDatabaseTarget('replica-3', {
|
||
status: 'loading',
|
||
projectRef: 'project-1',
|
||
})
|
||
).toEqual({ status: 'loading' })
|
||
})
|
||
|
||
it('resolves a matching loaded database as a replica', () => {
|
||
expect(
|
||
resolveNotebookDatabaseTarget(
|
||
'replica-3',
|
||
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
|
||
)
|
||
).toEqual({ status: 'replica' })
|
||
})
|
||
|
||
it('does not call an identifier absent from the loaded database list a replica', () => {
|
||
expect(resolveNotebookDatabaseTarget('replica-3', successfulDatabaseContext())).toEqual({
|
||
status: 'unknown',
|
||
})
|
||
})
|
||
|
||
it('preserves database lookup errors', () => {
|
||
expect(
|
||
resolveNotebookDatabaseTarget('replica-3', {
|
||
status: 'error',
|
||
projectRef: 'project-1',
|
||
})
|
||
).toEqual({ status: 'error' })
|
||
})
|
||
})
|
||
|
||
describe('notebookEntriesNeedDatabaseLookup', () => {
|
||
it('skips lookup for implicit and explicit primary cells', () => {
|
||
const entries: NotebookCellDiffEntry[] = [
|
||
{ _tag: 'unchanged', cell: wireDatabaseCell('cell-1') },
|
||
{ _tag: 'unchanged', cell: wireDatabaseCell('cell-2', undefined, 'project-1') },
|
||
]
|
||
|
||
expect(notebookEntriesNeedDatabaseLookup(entries, 'project-1')).toBe(false)
|
||
})
|
||
|
||
it('requires lookup when either side of a replacement has another identifier', () => {
|
||
const entries: NotebookCellDiffEntry[] = [
|
||
{
|
||
_tag: 'replaced',
|
||
before: wireDatabaseCell('cell-1'),
|
||
after: agentDatabaseCell('replica-3'),
|
||
operationIndex: 0,
|
||
},
|
||
]
|
||
|
||
expect(notebookEntriesNeedDatabaseLookup(entries, 'project-1')).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('getCellMetadata', () => {
|
||
it('hides metadata for markdown cells', () => {
|
||
expect(getCellMetadata(wireMarkdownCell('cell-1'), successfulDatabaseContext())).toEqual({
|
||
status: 'hidden',
|
||
})
|
||
})
|
||
|
||
it('labels an implicit primary database and table view', () => {
|
||
expect(getCellMetadata(wireDatabaseCell('cell-1'), successfulDatabaseContext())).toEqual({
|
||
status: 'ready',
|
||
source: 'Database: Primary',
|
||
view: 'Table',
|
||
})
|
||
})
|
||
|
||
it('labels a resolved replica descriptively', () => {
|
||
expect(
|
||
getCellMetadata(
|
||
wireDatabaseCell('cell-1', 'Signups', 'replica-3'),
|
||
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
|
||
)
|
||
).toEqual({
|
||
status: 'ready',
|
||
source: 'Database: Replica',
|
||
view: 'Table',
|
||
})
|
||
})
|
||
|
||
it('returns a loading state rather than guessing another identifier is a replica', () => {
|
||
expect(
|
||
getCellMetadata(wireDatabaseCell('cell-1', 'Signups', 'replica-3'), {
|
||
status: 'loading',
|
||
projectRef: 'project-1',
|
||
})
|
||
).toEqual({ status: 'loading' })
|
||
})
|
||
|
||
it('labels a missing loaded identifier as unknown', () => {
|
||
expect(
|
||
getCellMetadata(
|
||
wireDatabaseCell('cell-1', 'Signups', 'missing-database'),
|
||
successfulDatabaseContext()
|
||
)
|
||
).toEqual({ status: 'ready', source: 'Database: Unknown', view: 'Table' })
|
||
})
|
||
|
||
it('labels a log cell with its formatted time range', () => {
|
||
expect(getCellMetadata(wireLogCell('cell-1'), successfulDatabaseContext())).toEqual({
|
||
status: 'ready',
|
||
source: 'Time range: Last 7 days',
|
||
view: 'Table',
|
||
})
|
||
})
|
||
|
||
it('reports the chart summary as the view field for a chart-view database cell', () => {
|
||
expect(
|
||
getCellMetadata(chartDatabaseCell('cell-1', ['signups']), successfulDatabaseContext())
|
||
).toEqual({
|
||
status: 'ready',
|
||
source: 'Database: Primary',
|
||
view: 'Chart (bar, x: day, y: signups)',
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('getEntryMetadata', () => {
|
||
it('uses the cell metadata for non-replaced entries', () => {
|
||
expect(
|
||
getEntryMetadata(
|
||
{
|
||
_tag: 'unchanged',
|
||
cell: wireDatabaseCell('cell-1', 'Signups', 'replica-3'),
|
||
},
|
||
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
|
||
)
|
||
).toEqual({
|
||
status: 'ready',
|
||
text: 'Database: Replica',
|
||
})
|
||
})
|
||
|
||
it('returns a before → after pair when a replacement changes only metadata', () => {
|
||
expect(
|
||
getEntryMetadata(
|
||
{
|
||
_tag: 'replaced',
|
||
before: wireDatabaseCell('cell-1', 'Signups'),
|
||
after: agentDatabaseCell('replica-3'),
|
||
operationIndex: 0,
|
||
},
|
||
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
|
||
)
|
||
).toEqual({
|
||
status: 'ready',
|
||
text: 'Database: Primary → Database: Replica',
|
||
})
|
||
})
|
||
|
||
it('returns a loading state when either side still needs database data', () => {
|
||
expect(
|
||
getEntryMetadata(
|
||
{
|
||
_tag: 'replaced',
|
||
before: wireDatabaseCell('cell-1', 'Signups'),
|
||
after: agentDatabaseCell('replica-3'),
|
||
operationIndex: 0,
|
||
},
|
||
{ status: 'loading', projectRef: 'project-1' }
|
||
)
|
||
).toEqual({ status: 'loading' })
|
||
})
|
||
|
||
it('hides metadata entirely when a replacement changes neither the database nor the view', () => {
|
||
expect(
|
||
getEntryMetadata(
|
||
{
|
||
_tag: 'replaced',
|
||
before: wireDatabaseCell('cell-1', 'Signups'),
|
||
after: agentDatabaseCell(),
|
||
operationIndex: 0,
|
||
},
|
||
successfulDatabaseContext()
|
||
)
|
||
).toEqual({ status: 'hidden' })
|
||
})
|
||
|
||
it('surfaces only the view change from table to chart, omitting the unchanged database', () => {
|
||
expect(
|
||
getEntryMetadata(
|
||
{
|
||
_tag: 'replaced',
|
||
before: wireDatabaseCell('cell-1'),
|
||
after: agentChartDatabaseCell(['signups']),
|
||
operationIndex: 0,
|
||
},
|
||
successfulDatabaseContext()
|
||
)
|
||
).toEqual({
|
||
status: 'ready',
|
||
text: 'Table → Chart (bar, x: day, y: signups)',
|
||
})
|
||
})
|
||
|
||
it('surfaces only a chart parameter change, omitting the unchanged database', () => {
|
||
expect(
|
||
getEntryMetadata(
|
||
{
|
||
_tag: 'replaced',
|
||
before: chartDatabaseCell('cell-1', ['signups']),
|
||
after: agentChartDatabaseCell(['active_users']),
|
||
operationIndex: 0,
|
||
},
|
||
successfulDatabaseContext()
|
||
)
|
||
).toEqual({
|
||
status: 'ready',
|
||
text: 'Chart (bar, x: day, y: signups) → Chart (bar, x: day, y: active_users)',
|
||
})
|
||
})
|
||
|
||
it('surfaces only a database change, omitting the unchanged view', () => {
|
||
expect(
|
||
getEntryMetadata(
|
||
{
|
||
_tag: 'replaced',
|
||
before: chartDatabaseCell('cell-1', ['signups']),
|
||
after: agentChartDatabaseCell(['signups'], 'replica-3'),
|
||
operationIndex: 0,
|
||
},
|
||
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
|
||
)
|
||
).toEqual({
|
||
status: 'ready',
|
||
text: 'Database: Primary → Database: Replica',
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('summarizeNotebookDiff / formatNotebookDiffSummary', () => {
|
||
it('formats create mode with pluralized cell count', () => {
|
||
const entries: NotebookCellDiffEntry[] = [
|
||
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
|
||
{ _tag: 'unchanged', cell: wireMarkdownCell('b') },
|
||
]
|
||
|
||
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'create'))).toBe('2 cells')
|
||
})
|
||
|
||
it('formats create mode singular for a single cell', () => {
|
||
const entries: NotebookCellDiffEntry[] = [{ _tag: 'unchanged', cell: wireMarkdownCell('a') }]
|
||
|
||
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'create'))).toBe('1 cell')
|
||
})
|
||
|
||
it('formats update mode with a mix of categories, ignoring unchanged', () => {
|
||
const entries: NotebookCellDiffEntry[] = [
|
||
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
|
||
{ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 0 },
|
||
{ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 1 },
|
||
{ _tag: 'removed', cell: wireMarkdownCell('d'), operationIndex: 2 },
|
||
{
|
||
_tag: 'replaced',
|
||
before: wireMarkdownCell('e'),
|
||
after: agentMarkdownCell(),
|
||
operationIndex: 3,
|
||
},
|
||
{ _tag: 'moved', cell: wireMarkdownCell('f'), fromIndex: 0, operationIndex: 4 },
|
||
]
|
||
|
||
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('+2 −1 ~1 ↕1')
|
||
})
|
||
|
||
it('omits zero categories from the update summary', () => {
|
||
const entries: NotebookCellDiffEntry[] = [
|
||
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
|
||
{ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 0 },
|
||
]
|
||
|
||
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('+1')
|
||
})
|
||
|
||
it('falls back to "No changes" when every entry is unchanged', () => {
|
||
const entries: NotebookCellDiffEntry[] = [
|
||
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
|
||
{ _tag: 'unchanged', cell: wireMarkdownCell('b') },
|
||
]
|
||
|
||
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('No changes')
|
||
})
|
||
})
|