Files
supabase/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.test.ts
T
Joshen Lim 8ac64a4349 Add copy notebook as markdown action (#50200)
## Context

Adds a "Copy as Markdown" CTA for notebooks
<img width="265" height="198" alt="image"
src="https://github.com/user-attachments/assets/5eccf36e-24ea-4d2f-b1cf-72d5353b70a2"
/>

Query cell titles will be rendered as h3 tags and labelled either
Postgres or Logs - Clickhouse (with time range)
The query content will then be rendered as triple backticks with `sql`
e.g `
```sql...```
`

Query results will be copied to markdown if the query has been run, will otherwise be omitted
Also, if the query was updated (e.g content, source, etc) after it was run (as the result is hence stale), result will also be omitted
e.g:
| Notebook | Markdown |
| --- | --- |
| <img width="1291" height="630" alt="image" src="https://github.com/user-attachments/assets/c49f23e5-c70b-46dd-a298-6e1a90cd30d7" /> | <img width="731" height="536" alt="image" src="https://github.com/user-attachments/assets/4bbe3041-bd8e-42d3-86d2-1691e7a6bc7b" /> |
| <img width="1220" height="832" alt="image" src="https://github.com/user-attachments/assets/67de523a-18f7-4f1b-b764-7f0f3152f9e7" /> | <img width="757" height="803" alt="image" src="https://github.com/user-attachments/assets/d217504b-bee6-4088-9049-87ff647b9bc7" /> |



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

* **New Features**
  * Added Markdown export for notebook queries, results, errors, and time ranges.
  * Added error notifications when copying notebook content fails.

* **Bug Fixes**
  * Prevented stale query results after relevant source or time-range changes.
  * Improved Markdown export for queries containing backticks.
  * Escaped backslashes, pipes, and line breaks in Markdown tables.

* **Style**
  * Adjusted spacing for empty query-result messages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-11 11:25:22 +08:00

227 lines
6.6 KiB
TypeScript

import { untrustedSql } from '@supabase/pg-meta'
import { describe, expect, it } from 'vitest'
import {
changeCellSource,
cloneQueryCell,
DEFAULT_CELL_ROW_LIMIT,
getCellDisplay,
setCellRowLimit,
setCellSql,
shouldInvalidateResultOnSourceChange,
toQueryModel,
} from './QueryCell.utils'
import { type ChartConfig, type QueryCell } from '@/data/content/notebooks/notebook-schema'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
const CHART: ChartConfig = {
type: 'bar',
x_column: 'day',
y_series: ['signups'],
cumulative: false,
scale: 'linear',
show_labels: true,
}
const DATABASE_CELL: QueryCell = {
_tag: 'database_cell',
_id: 'cell-1',
title: 'Signups',
view: 'chart',
chart: CHART,
unchecked_sql: untrustedSql('select * from auth.users'),
row_limit: 50,
database_identifier: 'replica-1',
}
const LOG_CELL: QueryCell = {
_tag: 'log_cell',
_id: 'cell-2',
title: 'Edge errors',
view: 'table',
chart: CHART,
unchecked_sql: untrustedLogSql('select timestamp from logs'),
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
}
describe('changeCellSource', () => {
it('keeps the query when only the database changes', () => {
const next = changeCellSource(DATABASE_CELL, {
_tag: 'database',
database_identifier: 'replica-2',
})
expect(next).toEqual({ ...DATABASE_CELL, database_identifier: 'replica-2' })
})
it('keeps the query when only the log time range changes', () => {
const time_range = { _tag: 'relative_time_range', unit: 'day', amount: 7 } as const
const next = changeCellSource(LOG_CELL, { _tag: 'logs', time_range })
expect(next).toEqual({ ...LOG_CELL, time_range })
})
it('carries the query text over when moving from the database to logs', () => {
const time_range = { _tag: 'relative_time_range', unit: 'hour', amount: 1 } as const
const next = changeCellSource(DATABASE_CELL, { _tag: 'logs', time_range })
expect(next).toEqual({
_tag: 'log_cell',
_id: 'cell-1',
title: 'Signups',
view: 'chart',
chart: CHART,
unchecked_sql: 'select * from auth.users',
time_range,
})
})
it('carries the query text over and restores a default row limit when moving from logs to the database', () => {
const next = changeCellSource(LOG_CELL, { _tag: 'database', database_identifier: undefined })
expect(next).toEqual({
_tag: 'database_cell',
_id: 'cell-2',
title: 'Edge errors',
view: 'table',
chart: CHART,
unchecked_sql: 'select timestamp from logs',
row_limit: DEFAULT_CELL_ROW_LIMIT,
database_identifier: undefined,
})
})
it('applies the selected database when moving from logs to the database', () => {
const next = changeCellSource(LOG_CELL, {
_tag: 'database',
database_identifier: 'replica-2',
})
expect(next).toMatchObject({ _tag: 'database_cell', database_identifier: 'replica-2' })
})
it('preserves the chart across a backend change so display settings survive', () => {
const next = changeCellSource(DATABASE_CELL, {
_tag: 'logs',
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
})
expect(next.chart).toEqual(CHART)
expect(next.chart).not.toBe(DATABASE_CELL.chart)
})
})
describe('shouldInvalidateResultOnSourceChange', () => {
it('invalidates when the backend changes from database to logs', () => {
expect(
shouldInvalidateResultOnSourceChange(DATABASE_CELL, {
_tag: 'logs',
time_range: LOG_CELL.time_range,
})
).toBe(true)
})
it('invalidates when the backend changes from logs to database', () => {
expect(
shouldInvalidateResultOnSourceChange(LOG_CELL, {
_tag: 'database',
database_identifier: undefined,
})
).toBe(true)
})
it('invalidates a log cell result when the time range changes', () => {
expect(
shouldInvalidateResultOnSourceChange(LOG_CELL, {
_tag: 'logs',
time_range: { _tag: 'relative_time_range', unit: 'day', amount: 7 },
})
).toBe(true)
})
it('keeps a log cell result when the time range is unchanged', () => {
expect(
shouldInvalidateResultOnSourceChange(LOG_CELL, {
_tag: 'logs',
time_range: { ...LOG_CELL.time_range },
})
).toBe(false)
})
it('keeps a database cell result when only the connected database changes', () => {
expect(
shouldInvalidateResultOnSourceChange(DATABASE_CELL, {
_tag: 'database',
database_identifier: 'replica-2',
})
).toBe(false)
})
})
describe('setCellSql', () => {
it('writes the text back onto a database cell without touching its source', () => {
expect(setCellSql(DATABASE_CELL, 'select 1')).toEqual({
...DATABASE_CELL,
unchecked_sql: 'select 1',
})
})
it('writes the text back onto a log cell without touching its time range', () => {
expect(setCellSql(LOG_CELL, 'select 2')).toEqual({ ...LOG_CELL, unchecked_sql: 'select 2' })
})
})
describe('setCellRowLimit', () => {
it('writes the row limit onto a database cell without touching its query', () => {
expect(setCellRowLimit(DATABASE_CELL, 500)).toEqual({ ...DATABASE_CELL, row_limit: 500 })
})
it('leaves a log cell unchanged, since it has no row limit concept', () => {
expect(setCellRowLimit(LOG_CELL, 500)).toEqual(LOG_CELL)
})
})
describe('cloneQueryCell', () => {
it('copies the chart series array rather than aliasing it', () => {
const clone = cloneQueryCell(DATABASE_CELL)
expect(clone).toEqual(DATABASE_CELL)
expect(clone.chart?.y_series).not.toBe(DATABASE_CELL.chart?.y_series)
})
})
describe('getCellDisplay', () => {
it('keeps a configured chart while the table view is selected', () => {
expect(getCellDisplay({ ...DATABASE_CELL, view: 'table' })).toEqual({
view: 'table',
chart: CHART,
})
})
it('reports no chart when a cell has never configured one', () => {
expect(getCellDisplay({ ...DATABASE_CELL, view: 'table', chart: undefined })).toEqual({
view: 'table',
chart: undefined,
})
})
})
describe('toQueryModel', () => {
it('tags a database cell with its row limit and database', () => {
expect(toQueryModel(DATABASE_CELL, 'select 3')).toEqual({
_tag: 'database',
uncheckedSql: 'select 3',
database_identifier: 'replica-1',
rowLimit: 50,
})
})
it('tags a log cell with its time range and no row limit', () => {
expect(toQueryModel(LOG_CELL, 'select 4')).toEqual({
_tag: 'logs',
uncheckedSql: 'select 4',
time_range: LOG_CELL.time_range,
})
})
})