mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
Currently Query tabs in explorer do not support view options e.g. table vs chart. This adds display state to query tabs to match the behaviour of Notebooks ## To test - create a query in explorer - Run a query - Set the display options via toolbar <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Query results can now be displayed as either a table or chart. * Chart settings are saved with each query draft and restored when reopened. * Display preferences are maintained independently across query drafts. * Editing a preview query now converts it into a permanent tab. * **Bug Fixes** * Invalid or legacy display settings safely fall back to the table view without removing saved drafts. * Charts and empty states now use the available editor space more effectively. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
442 lines
17 KiB
TypeScript
442 lines
17 KiB
TypeScript
import { LOCAL_STORAGE_KEYS } from 'common'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
createExplorerQueryState,
|
|
EXPLORER_QUERY_PERSIST_DELAY,
|
|
MAX_PERSISTED_EXPLORER_QUERY_DRAFTS,
|
|
} from './explorer-query'
|
|
|
|
const createMemoryStorage = () => {
|
|
const values = new Map<string, string>()
|
|
|
|
return {
|
|
getItem: (key: string) => values.get(key) ?? null,
|
|
setItem: vi.fn((key: string, value: string) => values.set(key, value)),
|
|
removeItem: (key: string) => values.delete(key),
|
|
}
|
|
}
|
|
|
|
const LOGS_SOURCE = {
|
|
_tag: 'logs',
|
|
time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' },
|
|
} as const
|
|
|
|
describe('explorer query drafts', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => vi.useRealTimers())
|
|
|
|
it('persists and restores drafts within their project', () => {
|
|
const storage = createMemoryStorage()
|
|
const firstState = createExplorerQueryState(storage)
|
|
|
|
firstState.createDraft({ id: 'query-1', projectRef: 'project-a' })
|
|
firstState.updateDraft({ id: 'query-1', name: 'Active users', sql: 'select * from users' })
|
|
|
|
const secondState = createExplorerQueryState(storage)
|
|
|
|
expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(secondState.drafts['query-1']).toMatchObject({
|
|
_tag: 'database',
|
|
name: 'Active users',
|
|
uncheckedSql: 'select * from users',
|
|
projectRef: 'project-a',
|
|
})
|
|
expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-b' })).toBe(false)
|
|
})
|
|
|
|
it('persists source parameters and clears stale results when the backend changes', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
|
|
state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select 1' })
|
|
state.setResult({ id: 'query-1', result: { rows: [{ value: 1 }], executedAt: 1 } })
|
|
state.updateDraft({ id: 'query-1', source: LOGS_SOURCE })
|
|
|
|
expect(state.results['query-1']).toBeUndefined()
|
|
|
|
const restored = createExplorerQueryState(storage)
|
|
expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(restored.drafts['query-1']).toMatchObject(LOGS_SOURCE)
|
|
})
|
|
|
|
it('carries the query text over when the backend changes, rebranded for the new dialect', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
|
|
state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select * from users' })
|
|
state.updateDraft({ id: 'query-1', source: LOGS_SOURCE })
|
|
|
|
expect(state.drafts['query-1']).toMatchObject({
|
|
_tag: 'logs',
|
|
uncheckedSql: 'select * from users',
|
|
})
|
|
})
|
|
|
|
it('keeps the query when only the parameters of the same backend change', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
|
|
state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select * from users' })
|
|
state.setResult({ id: 'query-1', result: { rows: [{ value: 1 }], executedAt: 1 } })
|
|
state.updateDraft({
|
|
id: 'query-1',
|
|
source: { _tag: 'database', database_identifier: 'replica-1' },
|
|
})
|
|
|
|
expect(state.drafts['query-1']).toMatchObject({
|
|
_tag: 'database',
|
|
database_identifier: 'replica-1',
|
|
uncheckedSql: 'select * from users',
|
|
})
|
|
expect(state.results['query-1']).toBeDefined()
|
|
})
|
|
|
|
it('restores pre-source drafts as database queries', () => {
|
|
const storage = createMemoryStorage()
|
|
storage.setItem(
|
|
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
|
|
JSON.stringify({
|
|
'query-1': { name: 'Legacy query', sql: 'select 1', updatedAt: 1 },
|
|
})
|
|
)
|
|
|
|
const state = createExplorerQueryState(storage)
|
|
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(state.drafts['query-1']._tag).toBe('database')
|
|
})
|
|
|
|
it('ignores a malformed root value', () => {
|
|
const storage = createMemoryStorage()
|
|
storage.setItem(LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'), JSON.stringify([]))
|
|
|
|
const state = createExplorerQueryState(storage)
|
|
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(false)
|
|
})
|
|
|
|
it('drops entries with malformed draft fields', () => {
|
|
const storage = createMemoryStorage()
|
|
storage.setItem(
|
|
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
|
|
JSON.stringify({
|
|
'query-1': { name: 'Invalid query', sql: 123, updatedAt: 1 },
|
|
})
|
|
)
|
|
|
|
const state = createExplorerQueryState(storage)
|
|
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(false)
|
|
})
|
|
|
|
it('falls back to the database source when persisted source data is invalid', () => {
|
|
const storage = createMemoryStorage()
|
|
storage.setItem(
|
|
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
|
|
JSON.stringify({
|
|
'query-1': {
|
|
name: 'Recoverable query',
|
|
sql: 'select 1',
|
|
updatedAt: 1,
|
|
source: { id: 'logs', type: 'logs', parameters: {} },
|
|
},
|
|
})
|
|
)
|
|
|
|
const state = createExplorerQueryState(storage)
|
|
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(state.drafts['query-1']).toMatchObject({
|
|
_tag: 'database',
|
|
uncheckedSql: 'select 1',
|
|
})
|
|
})
|
|
|
|
it('debounces SQL persistence while updating in-memory state immediately', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
const key = LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a')
|
|
state.createDraft({ id: 'query-1', projectRef: 'project-a' })
|
|
storage.setItem.mockClear()
|
|
|
|
state.updateDraft({ id: 'query-1', sql: 's' })
|
|
state.updateDraft({ id: 'query-1', sql: 'se' })
|
|
state.updateDraft({ id: 'query-1', sql: 'select 1' })
|
|
|
|
expect(state.drafts['query-1'].uncheckedSql).toBe('select 1')
|
|
expect(storage.setItem).not.toHaveBeenCalled()
|
|
|
|
vi.advanceTimersByTime(EXPLORER_QUERY_PERSIST_DELAY)
|
|
|
|
expect(storage.setItem).toHaveBeenCalledOnce()
|
|
expect(JSON.parse(storage.getItem(key)!)['query-1'].sql).toBe('select 1')
|
|
})
|
|
|
|
it('flushes pending SQL persistence before the debounce elapses', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
const key = LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a')
|
|
state.createDraft({ id: 'query-1', projectRef: 'project-a' })
|
|
storage.setItem.mockClear()
|
|
|
|
state.updateDraft({ id: 'query-1', sql: 'select 1' })
|
|
state.flushPendingPersistence({ projectRef: 'project-a' })
|
|
|
|
expect(storage.setItem).toHaveBeenCalledOnce()
|
|
expect(JSON.parse(storage.getItem(key)!)['query-1'].sql).toBe('select 1')
|
|
|
|
vi.advanceTimersByTime(EXPLORER_QUERY_PERSIST_DELAY)
|
|
expect(storage.setItem).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('retains only the most recently updated persisted drafts', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
const key = LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a')
|
|
|
|
for (let index = 0; index <= MAX_PERSISTED_EXPLORER_QUERY_DRAFTS; index++) {
|
|
vi.setSystemTime(index)
|
|
state.createDraft({ id: `query-${index}`, projectRef: 'project-a' })
|
|
}
|
|
|
|
const persisted = JSON.parse(storage.getItem(key)!)
|
|
expect(Object.keys(persisted)).toHaveLength(MAX_PERSISTED_EXPLORER_QUERY_DRAFTS)
|
|
expect(persisted['query-0']).toBeUndefined()
|
|
expect(persisted[`query-${MAX_PERSISTED_EXPLORER_QUERY_DRAFTS}`]).toBeDefined()
|
|
})
|
|
|
|
it('persists and restores a per-draft row limit independently of other drafts', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
|
|
state.createDraft({ id: 'query-1', projectRef: 'project-a' })
|
|
state.createDraft({ id: 'query-2', projectRef: 'project-a' })
|
|
state.updateDraft({ id: 'query-1', rowLimit: 500 })
|
|
|
|
expect(state.drafts['query-1']).toMatchObject({ rowLimit: 500 })
|
|
expect(state.drafts['query-2']).toMatchObject({ rowLimit: 100 })
|
|
|
|
const restored = createExplorerQueryState(storage)
|
|
expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(restored.restoreDraft({ id: 'query-2', projectRef: 'project-a' })).toBe(true)
|
|
expect(restored.drafts['query-1']).toMatchObject({ rowLimit: 500 })
|
|
expect(restored.drafts['query-2']).toMatchObject({ rowLimit: 100 })
|
|
})
|
|
|
|
it('defaults the row limit for drafts persisted before row limits existed', () => {
|
|
const storage = createMemoryStorage()
|
|
storage.setItem(
|
|
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
|
|
JSON.stringify({
|
|
'query-1': { name: 'Legacy query', sql: 'select 1', updatedAt: 1 },
|
|
})
|
|
)
|
|
|
|
const state = createExplorerQueryState(storage)
|
|
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100 })
|
|
})
|
|
|
|
it('persists and restores result display options independently per draft', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
|
|
state.createDraft({ id: 'query-1', projectRef: 'project-a' })
|
|
state.createDraft({ id: 'query-2', projectRef: 'project-a' })
|
|
state.setDisplay({
|
|
id: 'query-1',
|
|
display: {
|
|
view: 'chart',
|
|
chart: {
|
|
type: 'line',
|
|
x_column: 'day',
|
|
y_series: ['requests'],
|
|
cumulative: true,
|
|
scale: 'log',
|
|
show_labels: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(state.drafts['query-1']).toMatchObject({
|
|
view: 'chart',
|
|
chart: { type: 'line', x_column: 'day', y_series: ['requests'] },
|
|
})
|
|
expect(state.drafts['query-2']).toMatchObject({ view: 'table', chart: undefined })
|
|
|
|
const restored = createExplorerQueryState(storage)
|
|
expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(restored.restoreDraft({ id: 'query-2', projectRef: 'project-a' })).toBe(true)
|
|
expect(restored.drafts['query-1']).toMatchObject({
|
|
view: 'chart',
|
|
chart: {
|
|
type: 'line',
|
|
x_column: 'day',
|
|
y_series: ['requests'],
|
|
cumulative: true,
|
|
scale: 'log',
|
|
show_labels: true,
|
|
},
|
|
})
|
|
expect(restored.drafts['query-2']).toMatchObject({ view: 'table', chart: undefined })
|
|
})
|
|
|
|
it('defaults legacy and malformed display options without dropping the draft', () => {
|
|
const storage = createMemoryStorage()
|
|
storage.setItem(
|
|
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
|
|
JSON.stringify({
|
|
'legacy-query': { name: 'Legacy query', sql: 'select 1', updatedAt: 1 },
|
|
'malformed-query': {
|
|
name: 'Malformed query',
|
|
sql: 'select 2',
|
|
updatedAt: 2,
|
|
view: 'map',
|
|
chart: { type: 'pie' },
|
|
},
|
|
})
|
|
)
|
|
|
|
const state = createExplorerQueryState(storage)
|
|
expect(state.restoreDraft({ id: 'legacy-query', projectRef: 'project-a' })).toBe(true)
|
|
expect(state.restoreDraft({ id: 'malformed-query', projectRef: 'project-a' })).toBe(true)
|
|
expect(state.drafts['legacy-query']).toMatchObject({ view: 'table', chart: undefined })
|
|
expect(state.drafts['malformed-query']).toMatchObject({ view: 'table', chart: undefined })
|
|
})
|
|
|
|
it('accepts every row limit the row limit menu can produce', () => {
|
|
const storage = createMemoryStorage()
|
|
|
|
for (const rowLimit of [-1, 100, 500, 1000]) {
|
|
storage.setItem(
|
|
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
|
|
JSON.stringify({
|
|
'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit },
|
|
})
|
|
)
|
|
|
|
const state = createExplorerQueryState(storage)
|
|
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(state.drafts['query-1']).toMatchObject({ rowLimit })
|
|
}
|
|
})
|
|
|
|
it('normalizes a fractional persisted row limit to the default', () => {
|
|
const storage = createMemoryStorage()
|
|
storage.setItem(
|
|
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
|
|
JSON.stringify({
|
|
'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit: 100.5 },
|
|
})
|
|
)
|
|
|
|
const state = createExplorerQueryState(storage)
|
|
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100 })
|
|
})
|
|
|
|
it('normalizes an out-of-range persisted row limit to the default', () => {
|
|
const storage = createMemoryStorage()
|
|
storage.setItem(
|
|
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
|
|
JSON.stringify({
|
|
'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit: 999999 },
|
|
})
|
|
)
|
|
|
|
const state = createExplorerQueryState(storage)
|
|
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100 })
|
|
})
|
|
|
|
it('normalizes a non-numeric persisted row limit to the default without dropping the draft', () => {
|
|
const storage = createMemoryStorage()
|
|
storage.setItem(
|
|
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
|
|
JSON.stringify({
|
|
'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit: 'unlimited' },
|
|
})
|
|
)
|
|
|
|
const state = createExplorerQueryState(storage)
|
|
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100, uncheckedSql: 'select 1' })
|
|
})
|
|
|
|
it('persists and restores a per-draft impersonated role independently of other drafts', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
|
|
state.createDraft({ id: 'query-1', projectRef: 'project-a' })
|
|
state.createDraft({ id: 'query-2', projectRef: 'project-a' })
|
|
state.setRole({ id: 'query-1', role: { type: 'postgrest', role: 'anon' } })
|
|
|
|
expect(state.drafts['query-1']).toMatchObject({ role: { type: 'postgrest', role: 'anon' } })
|
|
expect(state.drafts['query-2']).toMatchObject({ role: undefined })
|
|
|
|
const restored = createExplorerQueryState(storage)
|
|
expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(restored.restoreDraft({ id: 'query-2', projectRef: 'project-a' })).toBe(true)
|
|
expect(restored.drafts['query-1']).toMatchObject({
|
|
role: { type: 'postgrest', role: 'anon' },
|
|
})
|
|
expect(restored.drafts['query-2']).toMatchObject({ role: undefined })
|
|
})
|
|
|
|
it('clears a persisted role when set back to undefined', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
|
|
state.createDraft({ id: 'query-1', projectRef: 'project-a' })
|
|
state.setRole({ id: 'query-1', role: { type: 'postgrest', role: 'service_role' } })
|
|
state.setRole({ id: 'query-1', role: undefined })
|
|
|
|
expect(state.drafts['query-1']).toMatchObject({ role: undefined })
|
|
|
|
const restored = createExplorerQueryState(storage)
|
|
expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(restored.drafts['query-1']).toMatchObject({ role: undefined })
|
|
})
|
|
|
|
it('drops a malformed persisted role rather than failing to restore the draft', () => {
|
|
const storage = createMemoryStorage()
|
|
storage.setItem(
|
|
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
|
|
JSON.stringify({
|
|
'query-1': {
|
|
name: 'Query with bad role',
|
|
sql: 'select 1',
|
|
updatedAt: 1,
|
|
role: { type: 'postgrest', role: 'not-a-real-role' },
|
|
},
|
|
})
|
|
)
|
|
|
|
const state = createExplorerQueryState(storage)
|
|
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
|
|
expect(state.drafts['query-1']).toMatchObject({ role: undefined })
|
|
})
|
|
|
|
it('ignores role updates for logs drafts', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
|
|
state.createDraft({ id: 'query-1', projectRef: 'project-a', source: LOGS_SOURCE })
|
|
state.setRole({ id: 'query-1', role: { type: 'postgrest', role: 'anon' } })
|
|
|
|
expect(state.drafts['query-1']).not.toHaveProperty('role')
|
|
})
|
|
|
|
it('removes the persisted draft and its session result when its tab closes', () => {
|
|
const storage = createMemoryStorage()
|
|
const state = createExplorerQueryState(storage)
|
|
|
|
state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select 1' })
|
|
state.updateDraft({ id: 'query-1', sql: 'select 2' })
|
|
state.setResult({ id: 'query-1', result: { rows: [{ value: 1 }], executedAt: 1 } })
|
|
state.removeDraft({ id: 'query-1', projectRef: 'project-a' })
|
|
vi.advanceTimersByTime(EXPLORER_QUERY_PERSIST_DELAY)
|
|
|
|
expect(state.drafts['query-1']).toBeUndefined()
|
|
expect(state.results['query-1']).toBeUndefined()
|
|
expect(storage.getItem(LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'))).toBeNull()
|
|
})
|
|
})
|