{title && chartTitle}
- {highlightedValue !== undefined && !hideHighlightedValue && highlighted}
- {label}
+
+ {hasHighlightedValue && highlighted}
+ {label}
+
{!hideChartType && onChartStyleChange && (
diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx
index 5f891992832c2..b7b052b415998 100644
--- a/apps/studio/components/ui/Charts/ComposedChart.tsx
+++ b/apps/studio/components/ui/Charts/ComposedChart.tsx
@@ -4,6 +4,7 @@ import dayjs from 'dayjs'
import { formatBytes } from 'lib/helpers'
import { useTheme } from 'next-themes'
import { ComponentProps, useEffect, useState } from 'react'
+import { useChartSync } from './useChartSync'
import {
Area,
Bar,
@@ -101,6 +102,11 @@ export default function ComposedChart({
docsUrl,
}: ComposedChartProps) {
const { resolvedTheme } = useTheme()
+ const {
+ state: syncState,
+ updateState: updateSyncState,
+ clearState: clearSyncState,
+ } = useChartSync(syncId)
const [_activePayload, setActivePayload] = useState
(null)
const [_showMaxValue, setShowMaxValue] = useState(showMaxValue)
const [focusDataIndex, setFocusDataIndex] = useState(null)
@@ -108,7 +114,6 @@ export default function ComposedChart({
const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false)
const isDarkMode = resolvedTheme?.includes('dark')
- // Update chart colors when theme changes
useEffect(() => {
updateStackedChartColors(isDarkMode ?? false)
}, [resolvedTheme])
@@ -122,16 +127,13 @@ export default function ComposedChart({
return ''
}
- // Timestamps from auth logs can be in microseconds
if (typeof ts === 'number' && ts > 1e14) {
return day(ts / 1000).format(customDateFormat)
}
- // dayjs can handle ISO strings and millisecond numbers
return day(ts).format(customDateFormat)
}
- // Default props
const _XAxisProps = XAxisProps || {
interval: data.length - 2,
angle: 0,
@@ -161,46 +163,71 @@ export default function ComposedChart({
)
}
+ function computeHighlightedValue() {
+ const maxAttribute = attributes.find((a) => a.isMaxValue)
+ const referenceLines = attributes.filter(
+ (attribute) => attribute?.provider === 'reference-line'
+ )
+
+ const attributesToIgnore =
+ attributes?.filter((a) => a.omitFromTotal)?.map((a) => a.attribute) ?? []
+ const attributesToIgnoreFromTotal = [
+ ...attributesToIgnore,
+ ...(referenceLines?.map((a: MultiAttribute) => a.attribute) ?? []),
+ ...(maxAttribute?.attribute ? [maxAttribute?.attribute] : []),
+ ]
+
+ const lastDataPoint = data[data.length - 1]
+ ? Object.entries(data[data.length - 1])
+ .map(([key, value]) => ({
+ dataKey: key,
+ value: value as number,
+ }))
+ .filter(
+ (entry) =>
+ entry.dataKey !== 'timestamp' &&
+ entry.dataKey !== 'period_start' &&
+ attributes.some((attr) => attr.attribute === entry.dataKey && attr.enabled !== false)
+ )
+ : undefined
+
+ if (focusDataIndex !== null) {
+ return showTotal
+ ? calculateTotalChartAggregate(_activePayload, attributesToIgnoreFromTotal)
+ : data[focusDataIndex]?.[yAxisKey]
+ }
+
+ if (showTotal && lastDataPoint) {
+ return calculateTotalChartAggregate(lastDataPoint, attributesToIgnoreFromTotal)
+ }
+
+ return highlightedValue
+ }
+
+ function formatHighlightedValue(value: any) {
+ if (typeof value !== 'number') {
+ return value
+ }
+
+ if (shouldFormatBytes) {
+ const bytesValue = isNetworkChart ? Math.abs(value) : value
+ return formatBytes(bytesValue, valuePrecision)
+ }
+
+ return numberFormatter(value, valuePrecision)
+ }
+
const maxAttribute = attributes.find((a) => a.isMaxValue)
const maxAttributeData = {
name: maxAttribute?.attribute,
color: CHART_COLORS.REFERENCE_LINE,
}
- const lastDataPoint = !!data[data.length - 1]
- ? Object.entries(data[data.length - 1])
- .map(([key, value]) => ({
- dataKey: key,
- value: value as number,
- }))
- .filter(
- (entry) =>
- entry.dataKey !== 'timestamp' &&
- entry.dataKey !== 'period_start' &&
- attributes.some((attr) => attr.attribute === entry.dataKey && attr.enabled !== false)
- )
- : undefined
const referenceLines = attributes.filter((attribute) => attribute?.provider === 'reference-line')
const resolvedHighlightedLabel = getHeaderLabel()
- const attributesToIgnore =
- attributes?.filter((a) => a.omitFromTotal)?.map((a) => a.attribute) ?? []
-
- const attributesToIgnoreFromTotal = [
- ...attributesToIgnore,
- ...(referenceLines?.map((a: MultiAttribute) => a.attribute) ?? []),
- ...(maxAttribute?.attribute ? [maxAttribute?.attribute] : []),
- ]
-
- const resolvedHighlightedValue =
- focusDataIndex !== null
- ? showTotal
- ? calculateTotalChartAggregate(_activePayload, attributesToIgnoreFromTotal)
- : data[focusDataIndex]?.[yAxisKey]
- : showTotal && lastDataPoint
- ? calculateTotalChartAggregate(lastDataPoint, attributesToIgnoreFromTotal)
- : highlightedValue
+ const resolvedHighlightedValue = computeHighlightedValue()
const showHighlightActions =
chartHighlight?.coordinates.left &&
@@ -280,16 +307,7 @@ export default function ComposedChart({
title={title}
format={format}
customDateFormat={customDateFormat}
- highlightedValue={
- typeof resolvedHighlightedValue === 'number'
- ? shouldFormatBytes
- ? formatBytes(
- isNetworkChart ? Math.abs(resolvedHighlightedValue) : resolvedHighlightedValue,
- valuePrecision
- )
- : numberFormatter(resolvedHighlightedValue, valuePrecision)
- : resolvedHighlightedValue
- }
+ highlightedValue={formatHighlightedValue(resolvedHighlightedValue)}
highlightedLabel={resolvedHighlightedLabel}
minimalHeader={minimalHeader}
hideChartType={hideChartType}
@@ -299,6 +317,16 @@ export default function ComposedChart({
setShowMaxValue={maxAttribute ? setShowMaxValue : undefined}
hideHighlightedValue={hideHighlightedValue}
docsUrl={docsUrl}
+ syncId={syncId}
+ data={data}
+ xAxisKey={xAxisKey}
+ yAxisKey={yAxisKey}
+ xAxisIsDate={xAxisIsDate}
+ displayDateInUtc={displayDateInUtc}
+ valuePrecision={valuePrecision}
+ shouldFormatBytes={shouldFormatBytes}
+ isNetworkChart={isNetworkChart}
+ attributes={attributes}
/>
{
const datum = tooltipData?.activePayload?.[0]?.payload
@@ -362,7 +404,7 @@ export default function ComposedChart({
attributes={attributes}
valuePrecision={valuePrecision}
showTotal={showTotal}
- isActiveHoveredChart={isActiveHoveredChart}
+ isActiveHoveredChart={isActiveHoveredChart || (!!syncId && syncState.isHovering)}
/>
) : null
}
@@ -404,7 +446,6 @@ export default function ComposedChart({
name={
attributes?.find((a) => a.attribute === attribute.name)?.label || attribute.name
}
- // Show dot for the first attribute when a point is focused
dot={false}
/>
))}
diff --git a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx
index 400a7a40afc70..785874c922920 100644
--- a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx
+++ b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx
@@ -43,6 +43,7 @@ export interface ComposedChartHandlerProps {
isVisible?: boolean
docsUrl?: string
hide?: boolean
+ syncId?: string
}
/**
@@ -113,6 +114,7 @@ const ComposedChartHandler = ({
valuePrecision,
isVisible = true,
id,
+ syncId,
...otherProps
}: PropsWithChildren) => {
const router = useRouter()
@@ -124,7 +126,6 @@ const ComposedChartHandler = ({
const databaseIdentifier = state.selectedDatabaseId
- // Use the custom hook at the top level of the component
const attributeQueries = useAttributeQueries(
attributes,
ref,
@@ -136,7 +137,6 @@ const ComposedChartHandler = ({
isVisible
)
- // Combine all the data into a single dataset
const combinedData = useMemo(() => {
if (data) return data
@@ -146,7 +146,6 @@ const ComposedChartHandler = ({
const hasError = attributeQueries.some((query: any) => !query.data)
if (hasError) return undefined
- // Get all unique timestamps from all datasets
const timestamps = new Set()
attributeQueries.forEach((query: any) => {
query.data?.data?.forEach((point: any) => {
@@ -160,32 +159,26 @@ const ComposedChartHandler = ({
(_, index) => attributes[index].provider === 'reference-line'
)
- // Combine data points for each timestamp
const combined = Array.from(timestamps)
.sort()
.map((timestamp) => {
const point: any = { timestamp }
- // Add regular attributes
attributes.forEach((attr, index) => {
if (!attr) return
- // Handle custom value attributes (like disk size)
if (attr.customValue !== undefined) {
point[attr.attribute] = attr.customValue
return
}
- // Skip reference line attributes here, we'll add them below
if (attr.provider === 'reference-line') return
const queryData = attributeQueries[index]?.data?.data
const matchingPoint = queryData?.find((p: any) => p.period_start === timestamp)
let value = matchingPoint?.[attr.attribute] ?? 0
- // Apply value manipulation if provided
if (attr.manipulateValue && typeof attr.manipulateValue === 'function') {
- // Ensure value is a number before manipulation
const numericValue = typeof value === 'number' ? value : Number(value) || 0
value = attr.manipulateValue(numericValue)
}
@@ -193,7 +186,6 @@ const ComposedChartHandler = ({
point[attr.attribute] = value
})
- // Add reference line values for each timestamp
referenceLineQueries.forEach((query: any) => {
const attr = query.data.attribute
const value = query.data.total
@@ -213,7 +205,6 @@ const ComposedChartHandler = ({
const loading = isLoading || attributeQueries.some((query: any) => query.isLoading)
- // Calculate highlighted value based on the first attribute's data
const _highlightedValue = useMemo(() => {
if (highlightedValue !== undefined) return highlightedValue
@@ -266,7 +257,6 @@ const ComposedChartHandler = ({
)
}
- // Rest of the component remains similar, but pass all attributes to charts
return (
@@ -348,7 +339,7 @@ const useAttributeQueries = (
return {
data: {
- data: [], // Will be populated in combinedData
+ data: [],
attribute: line.attribute,
total: value,
maximum: value,
diff --git a/apps/studio/components/ui/Charts/StackedBarChart.tsx b/apps/studio/components/ui/Charts/StackedBarChart.tsx
index 1bf9b9a4528e6..9d25d99b64471 100644
--- a/apps/studio/components/ui/Charts/StackedBarChart.tsx
+++ b/apps/studio/components/ui/Charts/StackedBarChart.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Bar, BarChart, Cell, Legend, Tooltip, XAxis } from 'recharts'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
+import { useChartSync } from './useChartSync'
import ChartHeader from './ChartHeader'
import {
CHART_COLORS,
@@ -32,6 +33,7 @@ interface Props extends CommonChartProps {
hideLegend?: boolean
hideHeader?: boolean
stackColors?: ValidStackColor[]
+ syncId?: string
}
const StackedBarChart: React.FC = ({
size,
@@ -53,8 +55,14 @@ const StackedBarChart: React.FC = ({
hideLegend = false,
hideHeader = false,
stackColors = DEFAULT_STACK_COLORS,
+ syncId,
}) => {
const { Container } = useChartSize(size)
+ const {
+ updateState: updateSyncState,
+ clearState: clearSyncState,
+ state: syncState,
+ } = useChartSync(syncId)
const { dataKeys, stackedData, percentagesStackedData } = useStacked({
data,
xAxisKey,
@@ -91,6 +99,14 @@ const StackedBarChart: React.FC = ({
: resolvedHighlightedValue
}
highlightedLabel={resolvedHighlightedLabel}
+ syncId={syncId}
+ data={data}
+ xAxisKey={xAxisKey}
+ yAxisKey={yAxisKey}
+ xAxisIsDate={xAxisFormatAsDate}
+ displayDateInUtc={displayDateInUtc}
+ valuePrecision={valuePrecision}
+ attributes={[]}
/>
)}
@@ -108,8 +124,23 @@ const StackedBarChart: React.FC = ({
if (e.activeTooltipIndex !== focusDataIndex) {
setFocusDataIndex(e.activeTooltipIndex)
}
+
+ if (syncId) {
+ updateSyncState({
+ activeIndex: e.activeTooltipIndex,
+ activePayload: e.activePayload,
+ activeLabel: e.activeLabel,
+ isHovering: true,
+ })
+ }
+ }}
+ onMouseLeave={() => {
+ setFocusDataIndex(null)
+
+ if (syncId) {
+ clearSyncState()
+ }
}}
- onMouseLeave={() => setFocusDataIndex(null)}
>
{!hideLegend && (
diff --git a/apps/studio/components/ui/Charts/useChartSync.test.tsx b/apps/studio/components/ui/Charts/useChartSync.test.tsx
new file mode 100644
index 0000000000000..9f12b06e882bc
--- /dev/null
+++ b/apps/studio/components/ui/Charts/useChartSync.test.tsx
@@ -0,0 +1,153 @@
+import { renderHook, act } from '@testing-library/react'
+import { describe, it, expect, beforeEach } from 'vitest'
+import { useChartSync, cleanupChartSync } from './useChartSync'
+
+describe('useChartSync', () => {
+ beforeEach(() => {
+ cleanupChartSync('test-sync-1')
+ cleanupChartSync('test-sync-2')
+ })
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useChartSync('test-sync-1'))
+
+ expect(result.current.state).toEqual({
+ activeIndex: null,
+ activePayload: null,
+ activeLabel: null,
+ isHovering: false,
+ })
+ })
+
+ it('should update state correctly', () => {
+ const { result } = renderHook(() => useChartSync('test-sync-1'))
+
+ act(() => {
+ result.current.updateState({
+ activeIndex: 5,
+ activePayload: { test: 'data' },
+ activeLabel: 'test-label',
+ isHovering: true,
+ })
+ })
+
+ expect(result.current.state).toEqual({
+ activeIndex: 5,
+ activePayload: { test: 'data' },
+ activeLabel: 'test-label',
+ isHovering: true,
+ })
+ })
+
+ it('should clear state correctly', () => {
+ const { result } = renderHook(() => useChartSync('test-sync-1'))
+
+ // First update the state
+ act(() => {
+ result.current.updateState({
+ activeIndex: 5,
+ activePayload: { test: 'data' },
+ activeLabel: 'test-label',
+ isHovering: true,
+ })
+ })
+
+ // Then clear it
+ act(() => {
+ result.current.clearState()
+ })
+
+ expect(result.current.state).toEqual({
+ activeIndex: null,
+ activePayload: null,
+ activeLabel: null,
+ isHovering: false,
+ })
+ })
+
+ it('should sync state between multiple hooks with same syncId', () => {
+ const { result: result1 } = renderHook(() => useChartSync('test-sync-1'))
+ const { result: result2 } = renderHook(() => useChartSync('test-sync-1'))
+
+ act(() => {
+ result1.current.updateState({
+ activeIndex: 10,
+ activePayload: { test: 'data' },
+ activeLabel: 'test-label',
+ isHovering: true,
+ })
+ })
+
+ // Both hooks should have the same state
+ expect(result1.current.state.activeIndex).toBe(10)
+ expect(result2.current.state.activeIndex).toBe(10)
+ })
+
+ it('should not sync state between hooks with different syncId', () => {
+ const { result: result1 } = renderHook(() => useChartSync('test-sync-1'))
+ const { result: result2 } = renderHook(() => useChartSync('test-sync-2'))
+
+ act(() => {
+ result1.current.updateState({
+ activeIndex: 10,
+ activePayload: { test: 'data' },
+ activeLabel: 'test-label',
+ isHovering: true,
+ })
+ })
+
+ // Only the first hook should have updated state
+ expect(result1.current.state.activeIndex).toBe(10)
+ expect(result2.current.state.activeIndex).toBe(null)
+ })
+
+ it('should handle partial state updates', () => {
+ const { result } = renderHook(() => useChartSync('test-sync-1'))
+
+ // Set initial state
+ act(() => {
+ result.current.updateState({
+ activeIndex: 5,
+ activePayload: { test: 'data' },
+ activeLabel: 'test-label',
+ isHovering: true,
+ })
+ })
+
+ // Update only some fields
+ act(() => {
+ result.current.updateState({
+ activeIndex: 10,
+ isHovering: false,
+ })
+ })
+
+ expect(result.current.state).toEqual({
+ activeIndex: 10,
+ activePayload: { test: 'data' },
+ activeLabel: 'test-label',
+ isHovering: false,
+ })
+ })
+
+ it('should handle undefined syncId', () => {
+ const { result } = renderHook(() => useChartSync(undefined))
+
+ act(() => {
+ result.current.updateState({
+ activeIndex: 5,
+ activePayload: { test: 'data' },
+ activeLabel: 'test-label',
+ isHovering: true,
+ })
+ })
+
+ // State should remain unchanged when syncId is undefined
+ expect(result.current.state).toEqual({
+ activeIndex: null,
+ activePayload: null,
+ activeLabel: null,
+ isHovering: false,
+ })
+ })
+})
diff --git a/apps/studio/components/ui/Charts/useChartSync.tsx b/apps/studio/components/ui/Charts/useChartSync.tsx
new file mode 100644
index 0000000000000..bfc5400cbb64f
--- /dev/null
+++ b/apps/studio/components/ui/Charts/useChartSync.tsx
@@ -0,0 +1,117 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+export interface ChartSyncState {
+ activeIndex: number | null
+ activePayload: any
+ activeLabel: string | null
+ isHovering: boolean
+}
+
+export interface ChartSyncHook {
+ syncId: string
+ state: ChartSyncState
+ updateState: (newState: Partial) => void
+ clearState: () => void
+ subscribe: (callback: (state: ChartSyncState) => void) => () => void
+}
+
+const syncStateMap = new Map()
+const subscribersMap = new Map void>>()
+
+const getInitialState = (): ChartSyncState => ({
+ activeIndex: null,
+ activePayload: null,
+ activeLabel: null,
+ isHovering: false,
+})
+
+export function useChartSync(syncId?: string): ChartSyncHook {
+ const [state, setState] = useState(getInitialState())
+ const isInitialized = useRef(false)
+
+ useEffect(() => {
+ if (!syncId) return
+
+ if (!syncStateMap.has(syncId)) {
+ syncStateMap.set(syncId, getInitialState())
+ }
+
+ if (!subscribersMap.has(syncId)) {
+ subscribersMap.set(syncId, new Set())
+ }
+
+ setState(syncStateMap.get(syncId) || getInitialState())
+ isInitialized.current = true
+ }, [syncId])
+
+ useEffect(() => {
+ if (!syncId || !isInitialized.current) return
+
+ const subscribers = subscribersMap.get(syncId)!
+ const callback = (newState: ChartSyncState) => {
+ setState(newState)
+ }
+
+ subscribers.add(callback)
+
+ return () => {
+ subscribers.delete(callback)
+ }
+ }, [syncId])
+
+ const updateState = useCallback(
+ (newState: Partial) => {
+ if (!syncId) return
+
+ const currentState = syncStateMap.get(syncId) || getInitialState()
+ const updatedState = { ...currentState, ...newState }
+
+ syncStateMap.set(syncId, updatedState)
+
+ const subscribers = subscribersMap.get(syncId)
+ if (subscribers) {
+ subscribers.forEach((callback) => callback(updatedState))
+ }
+ },
+ [syncId]
+ )
+
+ const clearState = useCallback(() => {
+ if (!syncId) return
+
+ const clearedState = getInitialState()
+ syncStateMap.set(syncId, clearedState)
+
+ const subscribers = subscribersMap.get(syncId)
+ if (subscribers) {
+ subscribers.forEach((callback) => callback(clearedState))
+ }
+ }, [syncId])
+
+ const subscribe = useCallback(
+ (callback: (state: ChartSyncState) => void) => {
+ if (!syncId) return () => {}
+
+ const subscribers = subscribersMap.get(syncId)!
+ subscribers.add(callback)
+
+ return () => {
+ subscribers.delete(callback)
+ }
+ },
+ [syncId]
+ )
+
+ return {
+ syncId: syncId || '',
+ state,
+ updateState,
+ clearState,
+ subscribe,
+ }
+}
+
+export function cleanupChartSync(syncId: string) {
+ syncStateMap.delete(syncId)
+ subscribersMap.delete(syncId)
+}
diff --git a/apps/studio/hooks/misc/useCheckPermissions.ts b/apps/studio/hooks/misc/useCheckPermissions.ts
index 429377a5e8610..b77782fbda519 100644
--- a/apps/studio/hooks/misc/useCheckPermissions.ts
+++ b/apps/studio/hooks/misc/useCheckPermissions.ts
@@ -133,6 +133,9 @@ export function useGetProjectPermissions(
}
}
+/**
+ * @deprecated Use useAsyncCheckProjectPermissions instead
+ */
export function useCheckPermissions(
action: string,
resource: string,
diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx
index 313098869cbc2..dc375da34561c 100644
--- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx
+++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx
@@ -22,7 +22,7 @@ import {
} from 'data/analytics/functions-resource-usage-query'
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
-import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import type { ChartIntervals, NextPageWithLayout } from 'types'
import {
AlertDescription_Shadcn_,
@@ -142,11 +142,11 @@ const PageLayout: NextPageWithLayout = () => {
endDate.toISOString()
)
- const canReadFunction = useCheckPermissions(
+ const { isLoading: permissionsLoading, can: canReadFunction } = useAsyncCheckProjectPermissions(
PermissionAction.FUNCTIONS_READ,
functionSlug as string
)
- if (!canReadFunction) {
+ if (!canReadFunction && !permissionsLoading) {
return
}
diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx
index f7288df3aee0f..a598bb3712bdb 100644
--- a/apps/studio/pages/project/[ref]/reports/database.tsx
+++ b/apps/studio/pages/project/[ref]/reports/database.tsx
@@ -278,6 +278,7 @@ const DatabaseUsage = () => {
? true
: chart.showMaxValue
}
+ syncId={chart.syncId}
/>
) : (
{
- await page.getByLabel(`View ${tableName}`, { exact: true }).click()
- await page.getByLabel(`View ${tableName}`, { exact: true }).getByRole('button').nth(1).click()
+ await page.getByLabel(`View ${tableName}`).nth(0).click()
+ await page.getByLabel(`View ${tableName}`).getByRole('button').nth(1).click()
await page.getByText('Delete table').click()
await page.getByRole('checkbox', { name: 'Drop table with cascade?' }).click()
await page.getByRole('button', { name: 'Delete' }).click()
@@ -67,11 +67,9 @@ test.describe('Database', () => {
test.beforeAll(async ({ browser, ref }) => {
page = await browser.newPage()
await page.goto(toUrl(`/project/${ref}/editor`))
- await page.waitForTimeout(1000)
- if (
- (await page.getByRole('button', { name: `View ${databaseTableName}`, exact: true }).count()) >
- 0
- ) {
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=entity-types-public-0')
+
+ if ((await page.getByRole('button', { name: `View ${databaseTableName}` }).count()) > 0) {
await deleteTable(page, databaseTableName)
}
@@ -80,25 +78,27 @@ test.describe('Database', () => {
test.afterAll(async ({ ref }) => {
await page.goto(toUrl(`/project/${ref}/editor`))
- await page.waitForTimeout(1000)
- if (
- (await page.getByRole('button', { name: `View ${databaseTableName}`, exact: true }).count()) >
- 0
- ) {
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=entity-types-public-0')
+ if ((await page.getByRole('button', { name: `View ${databaseTableName}` }).count()) > 0) {
await deleteTable(page, databaseTableName)
}
})
test.describe('Schema Visualizer', () => {
test('actions works as expected', async ({ page, ref }) => {
- await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/schemas?schemea=public`))
+ await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/schemas?schema=public`))
// Wait for schema visualizer to load
- await waitForApiResponse(page, ref, 'tables?include_columns=true&included_schemas=public')
+ await waitForApiResponse(
+ page,
+ 'pg-meta',
+ ref,
+ 'tables?include_columns=true&included_schemas=public'
+ )
// validates table and column exists
await page.waitForTimeout(500)
- await expect(page.getByText(databaseTableName)).toBeVisible()
+ await expect(page.getByText(databaseTableName, { exact: true })).toBeVisible()
await expect(page.getByText(databaseColumnName)).toBeVisible()
// copies schema definition to clipboard
@@ -122,7 +122,12 @@ test.describe('Database', () => {
// changing schema -> auth
await page.getByTestId('schema-selector').click()
await page.getByRole('option', { name: 'auth' }).click()
- await waitForApiResponse(page, ref, 'tables?include_columns=true&included_schemas=auth')
+ await waitForApiResponse(
+ page,
+ 'pg-meta',
+ ref,
+ 'tables?include_columns=true&included_schemas=auth'
+ )
await expect(page.getByText('users')).toBeVisible()
await expect(page.getByText('sso_providers')).toBeVisible()
await expect(page.getByText('saml_providers')).toBeVisible()
@@ -140,13 +145,20 @@ test.describe('Database', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/tables?schema=public`))
// Wait for database tables to be populated
- await waitForApiResponse(page, ref, 'tables?include_columns=true&included_schemas=public')
+ await waitForApiResponse(
+ page,
+ 'pg-meta',
+ ref,
+ 'tables?include_columns=true&included_schemas=public'
+ )
// check new table button is present in public schema
await expect(page.getByRole('button', { name: 'New table' })).toBeVisible()
// validates database name is present and has accurate number of columns
- const tableRow = await page.getByRole('row', { name: databaseTableName })
+ const tableRow = await page.getByRole('row', {
+ name: `${databaseTableName} No description`,
+ })
await expect(tableRow).toContainText(databaseTableName)
await expect(tableRow).toContainText('3 columns')
@@ -154,7 +166,12 @@ test.describe('Database', () => {
await page.getByTestId('schema-selector').click()
await page.getByPlaceholder('Find schema...').fill('auth')
await page.getByRole('option', { name: 'auth' }).click()
- await waitForApiResponse(page, ref, 'tables?include_columns=true&included_schemas=auth')
+ await waitForApiResponse(
+ page,
+ 'pg-meta',
+ ref,
+ 'tables?include_columns=true&included_schemas=auth'
+ )
await expect(page.getByText('sso_providers')).toBeVisible()
// check new table button is not present in other schemas
await expect(page.getByRole('button', { name: 'New table' })).not.toBeVisible()
@@ -170,7 +187,12 @@ test.describe('Database', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/tables?schema=public`))
// Wait for database tables to be populated
- await waitForApiResponse(page, ref, 'tables?include_columns=true&included_schemas=public')
+ await waitForApiResponse(
+ page,
+ 'pg-meta',
+ ref,
+ 'tables?include_columns=true&included_schemas=public'
+ )
// drop database tables if exists
if ((await page.getByText(databaseTableNameNew, { exact: true }).count()) > 0) {
@@ -178,7 +200,7 @@ test.describe('Database', () => {
await page.getByRole('menuitem', { name: 'Delete table' }).click()
await page.getByRole('checkbox', { name: 'Drop table with cascade?' }).check()
await page.getByRole('button', { name: 'Delete' }).click()
- await waitForApiResponse(page, ref, 'query?key=table-delete')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-delete')
}
if ((await page.getByText(databaseTableNameUpdated, { exact: true }).count()) > 0) {
@@ -186,7 +208,7 @@ test.describe('Database', () => {
await page.getByRole('menuitem', { name: 'Delete table' }).click()
await page.getByRole('checkbox', { name: 'Drop table with cascade?' }).check()
await page.getByRole('button', { name: 'Delete' }).click()
- await waitForApiResponse(page, ref, 'query?key=table-delete')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-delete')
}
if ((await page.getByText(databaseTableNameDuplicate, { exact: true }).count()) > 0) {
@@ -197,7 +219,7 @@ test.describe('Database', () => {
await page.getByRole('menuitem', { name: 'Delete table' }).click()
await page.getByRole('checkbox', { name: 'Drop table with cascade?' }).check()
await page.getByRole('button', { name: 'Delete' }).click()
- await waitForApiResponse(page, ref, 'query?key=table-delete')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-delete')
}
// create a new table
@@ -206,8 +228,13 @@ test.describe('Database', () => {
await page.getByRole('button', { name: 'Save' }).click()
// validate table creation
- await waitForApiResponse(page, ref, 'query?key=table-create')
- await waitForApiResponse(page, ref, 'tables?include_columns=true&included_schemas=public')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
+ await waitForApiResponse(
+ page,
+ 'pg-meta',
+ ref,
+ 'tables?include_columns=true&included_schemas=public'
+ )
await expect(page.getByText(databaseTableNameNew, { exact: true })).toBeVisible()
// edit a new table
@@ -217,8 +244,13 @@ test.describe('Database', () => {
await page.getByRole('button', { name: 'Save' }).click()
// validate table update
- await waitForApiResponse(page, ref, 'query?key=table-update')
- await waitForApiResponse(page, ref, 'tables?include_columns=true&included_schemas=public')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-update')
+ await waitForApiResponse(
+ page,
+ 'pg-meta',
+ ref,
+ 'tables?include_columns=true&included_schemas=public'
+ )
await expect(page.getByText(databaseTableNameUpdated, { exact: true })).toBeVisible()
// duplicate table
@@ -229,8 +261,13 @@ test.describe('Database', () => {
await page.getByRole('button', { name: 'Save' }).click()
// validate table duplicate
- await waitForApiResponse(page, ref, 'query?key=')
- await waitForApiResponse(page, ref, 'tables?include_columns=true&included_schemas=public')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=')
+ await waitForApiResponse(
+ page,
+ 'pg-meta',
+ ref,
+ 'tables?include_columns=true&included_schemas=public'
+ )
await expect(page.getByText(databaseTableNameDuplicate, { exact: true })).toBeVisible()
// delete tables
@@ -241,7 +278,7 @@ test.describe('Database', () => {
await page.getByRole('menuitem', { name: 'Delete table' }).click()
await page.getByRole('checkbox', { name: 'Drop table with cascade?' }).check()
await page.getByRole('button', { name: 'Delete' }).click()
- await waitForApiResponse(page, ref, 'query?key=table-delete')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-delete')
await page
.getByRole('row', { name: `${databaseTableNameUpdated}` })
@@ -250,7 +287,7 @@ test.describe('Database', () => {
await page.getByRole('menuitem', { name: 'Delete table' }).click()
await page.getByRole('checkbox', { name: 'Drop table with cascade?' }).check()
await page.getByRole('button', { name: 'Delete' }).click()
- await waitForApiResponse(page, ref, 'query?key=table-delete')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-delete')
// validate navigating to table editor from database table page
await page.getByRole('row', { name: databaseTableName }).getByRole('button').click()
@@ -265,7 +302,12 @@ test.describe('Database', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/tables?schema=public`))
// Wait for database tables to be populated
- await waitForApiResponse(page, ref, 'tables?include_columns=true&included_schemas=public')
+ await waitForApiResponse(
+ page,
+ 'pg-meta',
+ ref,
+ 'tables?include_columns=true&included_schemas=public'
+ )
// navigate to table columns
const databaseRow = page.getByRole('row', { name: databaseTableName })
@@ -287,8 +329,8 @@ test.describe('Database', () => {
await page.getByRole('button', { name: 'Save' }).click()
// wait for response + validate
- await waitForApiResponse(page, ref, 'query?key=column-create')
- await waitForApiResponse(page, ref, 'query?key=table-editor-')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=column-create')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-editor-')
const columnDatabase2Row = page.getByRole('row', { name: databaseColumnName2 })
await expect(columnDatabase2Row).toContainText(databaseColumnName2)
await expect(columnDatabase2Row).toContainText('numeric')
@@ -300,8 +342,8 @@ test.describe('Database', () => {
await page.getByRole('button', { name: 'Save' }).click()
// wait for response + validate
- await waitForApiResponse(page, ref, 'query?key=column-update')
- await waitForApiResponse(page, ref, 'query?key=table-editor-')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=column-update')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-editor-')
// delete table column
const columnDatabase3Row = page.getByRole('row', { name: databaseColumnName3 })
@@ -311,8 +353,8 @@ test.describe('Database', () => {
await page.getByRole('button', { name: 'Delete' }).click()
// wait for response + validate
- await waitForApiResponse(page, ref, 'query?key=column-delete')
- await waitForApiResponse(page, ref, 'query?key=table-editor-')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=column-delete')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-editor-')
await expect(
page.getByText(`Successfully deleted column "${databaseColumnName3}"`),
'Delete confirmation toast should be visible'
@@ -330,7 +372,7 @@ test.describe('Database', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/triggers?schema=public`))
// Wait for database triggers to be populated
- await waitForApiResponse(page, ref, 'triggers')
+ await waitForApiResponse(page, 'pg-meta', ref, 'triggers')
// create new trigger button to exist in public schema
await expect(page.getByRole('button', { name: 'New trigger' })).toBeVisible()
@@ -353,7 +395,7 @@ test.describe('Database', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/triggers?schema=public`))
// Wait for database triggers to be populated
- await waitForApiResponse(page, ref, 'triggers')
+ await waitForApiResponse(page, 'pg-meta', ref, 'triggers')
// delete trigger if exists
if ((await page.getByRole('button', { name: databaseTriggerName }).count()) > 0) {
@@ -374,7 +416,7 @@ test.describe('Database', () => {
await page.getByRole('button', { name: 'New trigger' }).click()
await page.getByRole('textbox', { name: 'Name of trigger' }).fill(databaseTriggerName)
await page.getByRole('combobox').first().click()
- await page.getByRole('option', { name: `public.${databaseTableName}` }).click()
+ await page.getByRole('option', { name: `public.${databaseTableName}`, exact: true }).click()
await page.getByRole('checkbox').first().click()
await page.getByRole('checkbox').nth(1).click()
await page.getByRole('checkbox').nth(2).click()
@@ -383,7 +425,7 @@ test.describe('Database', () => {
await page.getByRole('button', { name: 'Create trigger' }).click()
// validate trigger creation
- await waitForApiResponse(page, ref, 'query?key=trigger-create')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=trigger-create')
await expect(
page.getByText(`Successfully created trigger`),
'Trigger creation confirmation toast should be visible'
@@ -401,7 +443,7 @@ test.describe('Database', () => {
await page.getByRole('button', { name: 'Create trigger' }).click()
// validate trigger update
- await waitForApiResponse(page, ref, 'query?key=trigger-update')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=trigger-update')
await expect(
page.getByText(`Successfully updated trigger`),
'Trigger updated confirmation toast should be visible'
@@ -435,7 +477,7 @@ test.describe('Database', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/indexes?schema=public`))
// Wait for database indexes to be populated
- await waitForApiResponse(page, ref, 'query?key=indexes-public')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=indexes-public')
// create new index button exists in public schema
await expect(page.getByRole('button', { name: 'Create index' })).toBeVisible()
@@ -444,7 +486,8 @@ test.describe('Database', () => {
await page.getByTestId('schema-selector').click()
await page.getByPlaceholder('Find schema...').fill('auth')
await page.getByRole('option', { name: 'auth' }).click()
- await waitForApiResponse(page, ref, 'query?key=indexes-auth')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=indexes-auth')
+ await page.waitForTimeout(500)
expect(page.getByText('sso_providers_pkey')).toBeVisible()
expect(page.getByText('confirmation_token_idx')).toBeVisible()
// create new index button does not exist in other schemas
@@ -469,7 +512,7 @@ test.describe('Database', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/indexes?schema=public`))
// Wait for database indexes to be populated
- await waitForApiResponse(page, ref, 'query?key=indexes-public')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=indexes-public')
// delete index if exist
const exists = (await page.getByRole('button', { name: databaseIndexName }).count()) > 0
@@ -486,7 +529,7 @@ test.describe('Database', () => {
// create new index
await page.getByRole('button', { name: 'Create index' }).click()
await page.getByRole('button', { name: 'Choose a table' }).click()
- await page.getByRole('option', { name: databaseTableName }).click()
+ await page.getByRole('option', { name: databaseTableName, exact: true }).click()
await page.getByText('Choose which columns to create an index on').click()
await page.getByRole('option', { name: databaseColumnName }).click()
await page.getByRole('button', { name: 'Create index' }).click()
@@ -512,7 +555,7 @@ test.describe('Database', () => {
// delete the index
await newIndexRow.getByRole('button', { name: 'Delete index' }).click()
await page.getByRole('button', { name: 'Confirm delete' }).click()
- await waitForApiResponse(page, ref, 'query?key=indexes')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=indexes')
await expect(
page.getByText('Successfully deleted index'),
'Index deletion confirmation toast should be visible'
@@ -525,7 +568,7 @@ test.describe('Database', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/roles`))
// Wait for database roles list to be populated
- await waitForApiResponse(page, ref, 'query?key=database-roles')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=database-roles')
// filter between active and all roles
await page.getByRole('button', { name: 'Active roles' }).click()
@@ -542,7 +585,7 @@ test.describe('Database', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/roles`))
// Wait for database roles to be populated
- await waitForApiResponse(page, ref, 'query?key=database-roles')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=database-roles')
// delete role if exists
const exists = (await page.getByRole('button', { name: databaseRoleName }).count()) > 0
@@ -572,7 +615,7 @@ test.describe('Database', () => {
await page.getByRole('button', { name: databaseRoleName }).getByRole('button').click()
await page.getByRole('menuitem', { name: 'Delete' }).click()
await page.getByRole('button', { name: 'Confirm' }).click()
- await waitForApiResponse(page, ref, 'query?key=roles-delete')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=roles-delete')
await expect(
page.getByText(`Successfully deleted role: ${databaseRoleName}`),
'Delete confirmation toast should be visible'
@@ -586,7 +629,7 @@ test.describe('Database Enumerated Types', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/types?schema=public`))
// Wait for database enumerated types to be populated
- await waitForApiResponse(page, ref, 'query?key=schemas')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=schemas')
// create new type button exists in public schema
await expect(page.getByRole('button', { name: 'Create type' })).toBeVisible()
@@ -611,7 +654,7 @@ test.describe('Database Enumerated Types', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/types?schema=public`))
// Wait for database roles list to be populated
- await waitForApiResponse(page, ref, 'query?key=schemas')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=schemas')
// if enum exists, delete it.
await page.waitForTimeout(500)
@@ -636,7 +679,7 @@ test.describe('Database Enumerated Types', () => {
await page.getByRole('button', { name: 'Create type' }).click()
// Wait for enum response to be completed and validate it
- await waitForApiResponse(page, ref, 'types')
+ await waitForApiResponse(page, 'pg-meta', ref, 'types')
const enumRow = page.getByRole('row', { name: `${databaseEnumName}` })
await expect(enumRow).toContainText(databaseEnumName)
await expect(enumRow).toContainText(`${databaseEnumValue1Name}, ${databaseEnumValue2Name}`)
@@ -668,7 +711,7 @@ test.describe('Database Functions', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/functions?schema=public`))
// Wait for database functions to be populated
- await waitForApiResponse(page, ref, 'query?key=database-functions')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=database-functions')
// create a new function button exists in public schema
await expect(page.getByRole('button', { name: 'Create a new function' })).toBeVisible()
@@ -693,7 +736,7 @@ test.describe('Database Functions', () => {
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/functions?schema=public`))
// Wait for database functions to be populated
- await waitForApiResponse(page, ref, 'query?key=database-functions')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=database-functions')
// delete function if exists
if ((await page.getByRole('button', { name: databaseFunctionName }).count()) > 0) {
@@ -724,8 +767,8 @@ END;`)
await page.getByRole('button', { name: 'Confirm' }).click()
// validate function creation
- await waitForApiResponse(page, ref, 'query?key=functions-create')
- await waitForApiResponse(page, ref, 'query?key=database-functions')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=functions-create')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=database-functions')
await expect(
page.getByText(`Successfully created function`),
'Trigger creation confirmation toast should be visible'
@@ -742,7 +785,7 @@ END;`)
await page.getByRole('button', { name: 'Confirm' }).click()
// validate function update
- await waitForApiResponse(page, ref, 'query?key=functions-update')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=functions-update')
await expect(
page.getByText(`Successfully updated function ${databaseFunctionNameUpdated}`),
'Function updated confirmation toast should be visible'
@@ -761,7 +804,7 @@ END;`)
await page
.getByRole('button', { name: `Delete function ${databaseFunctionNameUpdated}` })
.click()
- await waitForApiResponse(page, ref, 'query?key=functions-delete')
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=functions-delete')
await expect(
page.getByText(`Successfully removed function ${databaseFunctionNameUpdated}`),
'Delete confirmation toast should be visible'
diff --git a/e2e/studio/features/sql-editor.spec.ts b/e2e/studio/features/sql-editor.spec.ts
index 32f30dcd8cde5..795387c2b0479 100644
--- a/e2e/studio/features/sql-editor.spec.ts
+++ b/e2e/studio/features/sql-editor.spec.ts
@@ -1,19 +1,55 @@
import { expect, Page } from '@playwright/test'
+import fs from 'fs'
import { isCLI } from '../utils/is-cli'
import { test } from '../utils/test'
import { toUrl } from '../utils/to-url'
+import { waitForApiResponse } from '../utils/wait-for-response'
+import { waitForApiResponseWithTimeout } from '../utils/wait-for-response-with-timeout'
-const deleteQuery = async (page: Page, queryName: string) => {
+const sqlSnippetName = 'pw_sql_snippet'
+const sqlSnippetNameDuplicate = 'pw_sql_snippet (Duplicate)'
+const sqlSnippetNameFolder = 'pw_sql_snippet_folder'
+const sqlSnippetNameFavorite = 'pw_sql_snippet_favorite'
+const sqlSnippetNameShare = 'pw_sql_snippet_share'
+const sqlFolderName = 'pw_sql_folder'
+const sqlFolderNameUpdated = 'pw_sql_folder_updated'
+const newSqlSnippetName = 'Untitled query'
+
+/**
+ * Due to how sql editor is created, it's very annoying to test SQL editor in staging, I've created various workarounds to help mitigate flaky tests as much as possible.
+ *
+ * List of problems:
+ * 1. The connection string loading is very intermitten which leads to results not showing on the results tab. Sometimes it loads and sometimes it doesn't.
+ * > I've created a workaround by waiting for the api call which loads the connection string, and also ignore the error if the API call after 3 seconds. (Assuming that the connection string is already loaded)
+ * 2. The only way to access actions in the sidebar, is by right clicking unlike the table editor. This might cause issues as keyboard and mouse click actions are not consistent enough.
+ * > The best way to mitigate this, is clear all SQL snippets before and after each tests.
+ * 3. There would random have these errors "Sorry, An unexpected errors has occurred." when sharing sql snippet.
+ * > Have not figured out why this is happening. My guess is that when we click too fast things are not loaded properly and it's causing errors.
+ * > Full error: Cannot read properties of undefined (reading 'type')
+ *
+ */
+
+const deleteSqlSnippet = async (page: Page, ref: string, sqlSnippetName: string) => {
const privateSnippet = page.getByLabel('private-snippets')
- await privateSnippet.getByText(queryName).first().click({ button: 'right' })
+ await privateSnippet.getByText(sqlSnippetName).last().click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Delete query' }).click()
await expect(page.getByRole('heading', { name: 'Confirm to delete query' })).toBeVisible()
await page.getByRole('button', { name: 'Delete 1 query' }).click()
+ await waitForApiResponse(page, 'projects', ref, 'content', { method: 'DELETE' })
+ await page.waitForTimeout(500)
+}
+
+const deleteFolder = async (page: Page, ref: string, folderName: string) => {
+ await page.getByText(folderName, { exact: true }).click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Delete folder' }).click()
+ await page.getByRole('button', { name: 'Delete folder' }).click()
+ await waitForApiResponse(page, 'projects', ref, 'content/folders', {
+ method: 'DELETE',
+ })
}
test.describe('SQL Editor', () => {
let page: Page
- const pwTestQueryName = 'pw-test-query'
test.beforeAll(async ({ browser, ref }) => {
test.setTimeout(60000)
@@ -34,55 +70,48 @@ test.describe('SQL Editor', () => {
})
test.beforeEach(async ({ ref }) => {
+ test.setTimeout(60000)
+
+ await page.goto(toUrl(`/project/${ref}/sql/new?skip=true`))
+ // this is required to load the connection string
+ if (!isCLI()) {
+ await waitForApiResponseWithTimeout(
+ page,
+ (response) => response.url().includes('profile/permissions'),
+ 3000
+ )
+ await waitForApiResponseWithTimeout(
+ page,
+ (response) => response.url().includes('profile'),
+ 3000
+ )
+ }
+ })
+
+ test.afterAll(async ({ ref }) => {
if ((await page.getByLabel('private-snippets').count()) === 0) {
return
}
- // since in local, we don't have access to the supabase platform, reloading would reload all the sql snippets.
if (isCLI()) {
+ // In self-hosted environments, we don't have access to the supabase platform, reloading would clear/reset all the sql snippets.
await page.reload()
+ return
}
- // remove sql snippets for - "Untitled query" and "pw test query"
+ // remove sql snippets for "Untitled query" and "pw_sql_snippet"
const privateSnippet = page.getByLabel('private-snippets')
let privateSnippetText = await privateSnippet.textContent()
- while (privateSnippetText.includes('Untitled query')) {
- deleteQuery(page, 'Untitled query')
-
- await page.waitForResponse(
- (response) =>
- (response.url().includes(`projects/${ref}/content`) ||
- response.url().includes('projects/default/content')) &&
- response.request().method() === 'DELETE'
- )
- await expect(
- page.getByText('Successfully deleted 1 query'),
- 'Delete confirmation toast should be visible'
- ).toBeVisible({
- timeout: 50000,
- })
- await page.waitForTimeout(1000)
+ while (privateSnippetText.includes(newSqlSnippetName)) {
+ await deleteSqlSnippet(page, ref, newSqlSnippetName)
privateSnippetText =
(await page.getByLabel('private-snippets').count()) > 0
? await privateSnippet.textContent()
: ''
}
- while (privateSnippetText.includes(pwTestQueryName)) {
- deleteQuery(page, pwTestQueryName)
- await page.waitForResponse(
- (response) =>
- (response.url().includes(`projects/${ref}/content`) ||
- response.url().includes('projects/default/content')) &&
- response.request().method() === 'DELETE'
- )
- await expect(
- page.getByText('Successfully deleted 1 query'),
- 'Delete confirmation toast should be visible'
- ).toBeVisible({
- timeout: 50000,
- })
- await page.waitForTimeout(1000)
+ while (privateSnippetText.includes(sqlSnippetName)) {
+ await deleteSqlSnippet(page, ref, sqlSnippetName)
privateSnippetText =
(await page.getByLabel('private-snippets').count()) > 0
? await privateSnippet.textContent()
@@ -90,46 +119,29 @@ test.describe('SQL Editor', () => {
}
})
- test('should check if SQL editor can run simple commands', async () => {
- await page.getByTestId('sql-editor-new-query-button').click()
- await page.getByRole('menuitem', { name: 'Create a new snippet' }).click()
-
- // write some sql in the editor
- // This has to be done since the editor is not editable (input, textarea, etc.)
- await page.waitForTimeout(1000)
- const editor = page.getByRole('code').nth(0)
- await editor.click()
+ test('should check if SQL editor is working as expected', async ({ ref }) => {
+ await expect(page.getByText('Loading...')).not.toBeVisible()
+ await page.locator('.view-lines').click()
await page.keyboard.press('ControlOrMeta+KeyA')
await page.keyboard.type(`select 'hello world';`)
await page.getByTestId('sql-run-button').click()
// verify the result
- await expect(page.getByRole('gridcell', { name: 'hello world' })).toBeVisible({
- timeout: 5000,
- })
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { method: 'POST' })
+ await expect(page.getByRole('gridcell', { name: 'hello world' })).toBeVisible()
// SQL written in the editor should not be the previous query.
- await page.waitForTimeout(1000)
- await editor.click()
+ await page.locator('.view-lines').click()
await page.keyboard.press('ControlOrMeta+KeyA')
await page.keyboard.type(`select length('hello');`)
await page.getByTestId('sql-run-button').click()
// verify the result is updated.
- await expect(page.getByRole('gridcell', { name: '5' })).toBeVisible({
- timeout: 5000,
- })
- })
-
- test('destructive query would tripper a warning modal', async () => {
- await page.getByTestId('sql-editor-new-query-button').click()
- await page.getByRole('menuitem', { name: 'Create a new snippet' }).click()
+ await waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { method: 'POST' })
+ await expect(page.getByRole('gridcell', { name: '5' })).toBeVisible()
- // write some sql in the editor
- // This has to be done since the editor is not editable (input, textarea, etc.)
- await page.waitForTimeout(1000)
- const editor = page.getByRole('code').nth(0)
- await editor.click()
+ await expect(page.getByText('Loading...')).not.toBeVisible()
+ await page.locator('.view-lines').click()
await page.keyboard.press('ControlOrMeta+KeyA')
await page.keyboard.type(`delete table 'test';`)
await page.getByTestId('sql-run-button').click()
@@ -137,96 +149,386 @@ test.describe('SQL Editor', () => {
// verify warning modal is visible
expect(page.getByRole('heading', { name: 'Potential issue detected with' })).toBeVisible()
expect(page.getByText('Query has destructive')).toBeVisible()
-
- // reset test
await page.getByRole('button', { name: 'Cancel' }).click()
- await page.waitForTimeout(500)
- await editor.click()
+
+ // clear SQL snippet
+ if (!isCLI()) {
+ await deleteSqlSnippet(page, ref, newSqlSnippetName)
+ } else {
+ await page.reload()
+ }
+ })
+
+ test('exporting works as expected', async ({ ref }) => {
+ await expect(page.getByText('Loading...')).not.toBeVisible()
+ await page.locator('.view-lines').click()
await page.keyboard.press('ControlOrMeta+KeyA')
- await page.keyboard.press('Backspace')
+ await page.keyboard.type(`select 'hello world';`)
+ await page.getByTestId('sql-run-button').click()
+
+ // export as markdown
+ await page.getByRole('button', { name: 'Export' }).click()
+ await page.getByRole('menuitem', { name: 'Copy as markdown' }).click()
+ await page.waitForTimeout(500)
+ const copiedMarkdownResult = await page.evaluate(() => navigator.clipboard.readText())
+ expect(copiedMarkdownResult).toBe(`| ?column? |
+| ----------- |
+| hello world |`)
+
+ // export as JSON
+ await page.getByRole('button', { name: 'Export' }).click()
+ await page.getByRole('menuitem', { name: 'Copy as JSON' }).click()
+ await page.waitForTimeout(500)
+ const copiedJsonResult = await page.evaluate(() => navigator.clipboard.readText())
+ expect(copiedJsonResult).toBe(`[
+ {
+ "?column?": "hello world"
+ }
+]`)
+
+ // export as CSV
+ const downloadPromise = page.waitForEvent('download')
+ await page.getByRole('button', { name: 'Export' }).click()
+ await page.getByRole('menuitem', { name: 'Download CSV' }).click()
+ const download = await downloadPromise
+ expect(download.suggestedFilename()).toContain('.csv')
+ const downloadPath = await download.path()
+ const csvContent = fs.readFileSync(downloadPath, 'utf-8').replace(/\r?\n/g, '\n')
+ expect(csvContent).toBe(`?column?
+hello world`)
+ fs.unlinkSync(downloadPath)
+
+ // clear SQL snippet
+ if (!isCLI()) {
+ await deleteSqlSnippet(page, ref, newSqlSnippetName)
+ } else {
+ await page.reload()
+ }
})
- test('should create and load a new snippet', async ({ ref }) => {
- const runButton = page.getByTestId('sql-run-button')
- await page.getByRole('button', { name: 'Favorites' }).click()
- await page.getByRole('button', { name: 'Shared' }).click()
+ test('snippet favourite works as expected', async ({ ref }) => {
+ if (!isCLI()) {
+ // clean up private snippets and snippets shared with the team
+ await waitForApiResponseWithTimeout(
+ page,
+ (response) => response.url().includes('query?key=table-columns'),
+ 3000
+ )
+ const privateSnippetSection = page.getByLabel('private-snippets')
+ if ((await privateSnippetSection.getByText(newSqlSnippetName, { exact: true }).count()) > 0) {
+ await deleteSqlSnippet(page, ref, newSqlSnippetName)
+ }
- // write some sql in the editor
- await page.getByTestId('sql-editor-new-query-button').click()
- await page.getByRole('menuitem', { name: 'Create a new snippet' }).click()
- const editor = page.getByRole('code').nth(0)
- await page.waitForTimeout(1000)
- await editor.click()
+ if (
+ (await privateSnippetSection.getByText(sqlSnippetNameFavorite, { exact: true }).count()) > 0
+ ) {
+ await deleteSqlSnippet(page, ref, sqlSnippetNameFavorite)
+ }
+ }
+
+ // create sql snippet
+ await expect(page.getByText('Loading...')).not.toBeVisible()
+ await page.locator('.view-lines').click()
+ await page.keyboard.press('ControlOrMeta+KeyA')
await page.keyboard.type(`select 'hello world';`)
- await expect(page.getByText("select 'hello world';")).toBeVisible()
- await runButton.click()
+ await page.getByTestId('sql-run-button').click()
- // snippet exists
- const privateSnippet = page.getByLabel('private-snippets')
- await expect(privateSnippet).toContainText('Untitled query')
+ // rename snippet
+ const privateSnippetSection = page.getByLabel('private-snippets')
+ await privateSnippetSection.getByText(newSqlSnippetName).click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Rename query', exact: true }).click()
+ await expect(page.getByRole('heading', { name: 'Rename' })).toBeVisible()
+ await page.getByRole('textbox', { name: 'Name' }).fill(sqlSnippetNameFavorite)
+ await page.getByRole('button', { name: 'Rename query', exact: true }).click()
+ await waitForApiResponse(page, 'projects', ref, 'content', { method: 'PUT' })
+ await expect(
+ privateSnippetSection.getByText(sqlSnippetNameFavorite, { exact: true })
+ ).toBeVisible()
+ await page.waitForTimeout(2000) // wait for sql snippets cache to invalidate.
+
+ // open up shared and favourites sections
+ await page.getByRole('button', { name: 'Favorites' }).click()
// favourite snippets
await page.getByTestId('sql-editor-utility-actions').click()
await page.getByRole('menuitem', { name: 'Add to favorites', exact: true }).click()
+ await waitForApiResponse(page, 'projects', ref, 'content', { method: 'PUT' })
const favouriteSnippetsSection = page.getByLabel('favorite-snippets')
- await expect(favouriteSnippetsSection).toContainText('Untitled query')
+ await expect(
+ favouriteSnippetsSection.getByText(sqlSnippetNameFavorite, { exact: true })
+ ).toBeVisible()
// unfavorite snippets
- await page.waitForTimeout(500)
await page.getByTestId('sql-editor-utility-actions').click()
await page.getByRole('menuitem', { name: 'Remove from favorites' }).click()
- await expect(favouriteSnippetsSection).not.toBeVisible()
+ await waitForApiResponse(page, 'projects', ref, 'content', { method: 'PUT' })
+ await expect(
+ favouriteSnippetsSection.getByText(sqlSnippetNameFavorite, { exact: true })
+ ).not.toBeVisible()
+
+ // clear SQL snippet
+ if (!isCLI()) {
+ await deleteSqlSnippet(page, ref, sqlSnippetNameFavorite)
+ } else {
+ await page.reload()
+ }
+ })
+
+ test('share with team works as expected', async ({ ref }) => {
+ if (!isCLI()) {
+ console.log('Sharing and unsharing SQL snippet has issues in staging')
+ return
+ }
+
+ // clean up private snippets and snippets shared with the team
+ await waitForApiResponseWithTimeout(
+ page,
+ (response) => response.url().includes('query?key=table-columns'),
+ 3000
+ )
+ const privateSnippetSection = page.getByLabel('private-snippets')
+ if ((await privateSnippetSection.getByText(newSqlSnippetName, { exact: true }).count()) > 0) {
+ await deleteSqlSnippet(page, ref, newSqlSnippetName)
+ }
+
+ if ((await privateSnippetSection.getByText(sqlSnippetNameShare, { exact: true }).count()) > 0) {
+ // this would delete snippets from both favorite and private snippets sections
+ await deleteSqlSnippet(page, ref, sqlSnippetNameShare)
+ }
+
+ if ((await page.getByRole('button', { name: 'Shared' }).textContent()).includes('(')) {
+ const sharedSnippetSection = page.getByLabel('project-level-snippets')
+ await page.getByRole('button', { name: 'Shared' }).click()
+
+ let sharedSnippetText = await sharedSnippetSection.textContent()
+ while (sharedSnippetText.includes(sqlSnippetNameShare)) {
+ await sharedSnippetSection.getByText(sqlSnippetName).last().click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Delete query' }).click()
+ await expect(page.getByRole('heading', { name: 'Confirm to delete query' })).toBeVisible()
+ await page.getByRole('button', { name: 'Delete 1 query' }).click()
+ await waitForApiResponse(page, 'projects', ref, 'content', { method: 'DELETE' })
+ await page.waitForTimeout(500)
+ sharedSnippetText =
+ (await page.getByLabel('project-level-snippets').count()) > 0
+ ? await sharedSnippetSection.textContent()
+ : ''
+ }
+ await page.getByRole('button', { name: 'Shared' }).click()
+ }
+
+ // create sql snippet
+ await expect(page.getByText('Loading...')).not.toBeVisible()
+ await page.locator('.view-lines').click()
+ await page.keyboard.press('ControlOrMeta+KeyA')
+ await page.keyboard.type(`select 'hello world';`)
+ await page.getByTestId('sql-run-button').click()
// rename snippet
- await privateSnippet.getByText('Untitled query').click({ button: 'right' })
+ await privateSnippetSection.getByText(newSqlSnippetName).click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Rename query', exact: true }).click()
await expect(page.getByRole('heading', { name: 'Rename' })).toBeVisible()
- await page.getByRole('textbox', { name: 'Name' }).fill(pwTestQueryName)
+ await page.getByRole('textbox', { name: 'Name' }).fill(sqlSnippetNameShare)
await page.getByRole('button', { name: 'Rename query', exact: true }).click()
- await page.waitForResponse(
- (response) =>
- (response.url().includes(`projects/${ref}/content`) ||
- response.url().includes('projects/default/content')) &&
- response.request().method() === 'PUT' &&
- response.status().toString().startsWith('2')
- )
- await expect(privateSnippet.getByText(pwTestQueryName, { exact: true })).toBeVisible({
- timeout: 50000,
- })
- const privateSnippet2 = await privateSnippet.getByText(pwTestQueryName, { exact: true })
+ await waitForApiResponse(page, 'projects', ref, 'content', { method: 'PUT' })
+ await expect(
+ privateSnippetSection.getByText(sqlSnippetNameShare, { exact: true })
+ ).toBeVisible()
+ await page.waitForTimeout(2000) // wait for sql snippets cache to invalidate.
+
+ // open up shared and favourites sections
+ await page.getByRole('button', { name: 'Shared' }).click()
// share with a team
- await privateSnippet2.click({ button: 'right' })
+ const snippet = privateSnippetSection.getByText(sqlSnippetNameShare, { exact: true })
+ await snippet.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Share query with team' }).click()
await expect(page.getByRole('heading', { name: 'Confirm to share query' })).toBeVisible()
+ await page.waitForTimeout(1000)
await page.getByRole('button', { name: 'Share query', exact: true }).click()
- await page.waitForResponse(
- (response) =>
- (response.url().includes(`projects/${ref}/content`) ||
- response.url().includes('projects/default/content')) &&
- response.request().method() === 'PUT' &&
- response.status().toString().startsWith('2')
- )
- const sharedSnippet = await page.getByLabel('project-level-snippets')
- await expect(sharedSnippet).toContainText(pwTestQueryName)
+ await waitForApiResponse(page, 'projects', ref, 'content', { method: 'PUT' })
+ const sharedSnippet = page.getByLabel('project-level-snippets')
+ await expect(sharedSnippet.getByText(sqlSnippetNameShare, { exact: true })).toBeVisible({
+ timeout: 5000,
+ })
// unshare a snippet
- await sharedSnippet.getByText(pwTestQueryName).click({ button: 'right' })
+ await sharedSnippet.getByText(sqlSnippetNameShare).click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Unshare query with team' }).click()
await expect(page.getByRole('heading', { name: 'Confirm to unshare query:' })).toBeVisible()
await page.getByRole('button', { name: 'Unshare query', exact: true }).click()
- await expect(sharedSnippet).not.toBeVisible()
+ await expect(sharedSnippet.getByText(sqlSnippetNameShare, { exact: true })).not.toBeVisible()
- // delete snippet (for non-local environment)
+ // clear SQL snippet
if (!isCLI()) {
- deleteQuery(page, pwTestQueryName)
+ await deleteSqlSnippet(page, ref, sqlSnippetNameShare)
+ } else {
+ await page.reload()
+ }
+ })
+
+ test('folders works as expected', async ({ ref }) => {
+ if (!isCLI()) {
+ // clean up folders and snippets
+ await waitForApiResponseWithTimeout(
+ page,
+ (response) => response.url().includes('query?key=table-columns'),
+ 3000
+ )
+ const privateSnippetSection = page.getByLabel('private-snippets')
+ if ((await privateSnippetSection.getByText(sqlFolderName, { exact: true }).count()) > 0) {
+ await deleteFolder(page, ref, sqlFolderName)
+ }
+ if (
+ (await privateSnippetSection.getByText(sqlFolderNameUpdated, { exact: true }).count()) > 0
+ ) {
+ await deleteFolder(page, ref, sqlFolderNameUpdated)
+ }
+ } else {
+ console.log('This test does not work in self-hosted environments.')
+ return
+ }
+
+ // create sql snippet
+ await expect(page.getByText('Loading...')).not.toBeVisible()
+ await page.locator('.view-lines').click()
+ await page.keyboard.press('ControlOrMeta+KeyA')
+ await page.keyboard.type(`select 'hello world';`)
+ await page.getByTestId('sql-run-button').click()
+
+ // rename snippet
+ const privateSnippetSection = page.getByLabel('private-snippets')
+ await privateSnippetSection.getByText(newSqlSnippetName).click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Rename query', exact: true }).click()
+ await expect(page.getByRole('heading', { name: 'Rename' })).toBeVisible()
+ await page.getByRole('textbox', { name: 'Name' }).fill(sqlSnippetNameFolder)
+ await page.getByRole('button', { name: 'Rename query', exact: true }).click()
+ await waitForApiResponse(page, 'projects', ref, 'content', { method: 'PUT' })
+ await expect(
+ privateSnippetSection.getByText(sqlSnippetNameFolder, { exact: true })
+ ).toBeVisible()
+ await page.waitForTimeout(2000) // wait for sql snippets cache to invalidate.
+
+ // create a folder
+ await page.getByTestId('sql-editor-new-query-button').click()
+ await page.getByRole('menuitem', { name: 'Create a new folder' }).click()
+ await page.getByRole('tree', { name: 'private-snippets' }).getByRole('textbox').click()
+ await page
+ .getByRole('tree', { name: 'private-snippets' })
+ .getByRole('textbox')
+ .fill(sqlFolderName)
+ await page.waitForTimeout(500)
+ await page.locator('.view-lines').click() // blur input and renames folder
+ await waitForApiResponse(page, 'projects', ref, 'content/folders', { method: 'POST' })
+ await expect(page.getByText('Successfully created folder')).toBeVisible()
- await expect(
- page.getByText('Successfully deleted 1 query'),
- 'Delete confirmation toast should be visible'
- ).toBeVisible({
- timeout: 50000,
- })
+ // rename a folder
+ await privateSnippetSection.getByText(sqlFolderName).click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Rename folder' }).click()
+ await page
+ .getByRole('treeitem', { name: sqlFolderName })
+ .getByRole('textbox')
+ .fill(sqlFolderNameUpdated)
+ await page.waitForTimeout(500)
+ await page.locator('.view-lines').click() // blur input and renames folder
+ await waitForApiResponse(page, 'projects', ref, 'content/folders', { method: 'PATCH' })
+
+ // move sql snippet into folder
+ await privateSnippetSection.getByText(sqlSnippetNameFolder).click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Move query' }).click()
+ await page.getByRole('button', { name: 'Root of the editor (Current)' }).click()
+ await page.getByRole('option', { name: sqlFolderNameUpdated, exact: true }).click()
+ await page.getByRole('button', { name: 'Move file' }).click()
+ await waitForApiResponse(page, 'projects', ref, 'content', { method: 'PUT' })
+ await expect(page.getByText('Successfully moved')).toBeVisible({
+ timeout: 5000,
+ })
+
+ // delete a folder + deleting a folder would also remove the SQL snippets within
+ await privateSnippetSection
+ .getByText(sqlFolderNameUpdated, { exact: true })
+ .click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Delete folder' }).click()
+ await expect(page.getByRole('heading', { name: 'Confirm to delete folder' })).toBeVisible()
+ await page.getByRole('button', { name: 'Delete folder' }).click()
+ await waitForApiResponse(page, 'projects', ref, 'content/folders', {
+ method: 'DELETE',
+ })
+ await expect(page.getByText('Successfully deleted folder', { exact: true })).toBeVisible({
+ timeout: 5000,
+ })
+ await expect(privateSnippetSection.getByText(sqlFolderNameUpdated)).not.toBeVisible()
+ await expect(privateSnippetSection.getByText(sqlSnippetNameFolder)).not.toBeVisible()
+ })
+
+ test('other SQL snippets actions work as expected', async ({ ref }) => {
+ if (!isCLI()) {
+ // clean up 'Untitled query', 'pw_sql_snippet' and 'pw_sql_snippet (Duplicate)' snippets if exists
+ await waitForApiResponseWithTimeout(
+ page,
+ (response) => response.url().includes('query?key=table-columns'),
+ 3000
+ )
+ const privateSnippet = page.getByLabel('private-snippets')
+ if ((await privateSnippet.getByText(newSqlSnippetName).count()) > 0) {
+ deleteSqlSnippet(page, ref, newSqlSnippetName)
+ }
+ if ((await privateSnippet.getByText(sqlSnippetNameDuplicate, { exact: true }).count()) > 0) {
+ await deleteSqlSnippet(page, ref, sqlSnippetNameDuplicate)
+ }
+ if ((await privateSnippet.getByText(sqlSnippetName, { exact: true }).count()) > 0) {
+ await deleteSqlSnippet(page, ref, sqlSnippetName)
+ }
+ } else {
+ console.log('This test does not work in self-hosted environments.')
+ return
}
+
+ // create sql snippet
+ await expect(page.getByText('Loading...')).not.toBeVisible()
+ await page.locator('.view-lines').click()
+ await page.keyboard.press('ControlOrMeta+KeyA')
+ await page.keyboard.type(`select 'hello world';`)
+ await page.getByTestId('sql-run-button').click()
+
+ // rename snippet
+ const privateSnippetSection = page.getByLabel('private-snippets')
+ await privateSnippetSection.getByText(newSqlSnippetName).click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Rename query', exact: true }).click()
+ await expect(page.getByRole('heading', { name: 'Rename' })).toBeVisible()
+ await page.getByRole('textbox', { name: 'Name' }).fill(sqlSnippetName)
+ await page.getByRole('button', { name: 'Rename query', exact: true }).click()
+ await waitForApiResponse(page, 'projects', ref, 'content', { method: 'PUT' })
+ await expect(privateSnippetSection.getByText(sqlSnippetName, { exact: true })).toBeVisible()
+ await page.waitForTimeout(2000) // wait for sql snippets cache to invalidate.
+
+ // duplicate SQL snippet
+ await privateSnippetSection
+ .getByTitle(sqlSnippetName, { exact: true })
+ .click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Duplicate query' }).click()
+ await waitForApiResponse(page, 'projects', ref, 'content', { method: 'PUT' })
+ await expect(
+ privateSnippetSection.getByText(sqlSnippetNameDuplicate, { exact: true })
+ ).toBeVisible()
+
+ // filter SQL snippets
+ const searchBar = page.getByRole('textbox', { name: 'Search queries...' })
+ await searchBar.fill('Duplicate')
+ await expect(page.getByText(sqlSnippetName, { exact: true })).not.toBeVisible()
+ await expect(page.getByRole('link', { name: sqlSnippetNameDuplicate })).toBeVisible()
+ await expect(page.getByText('result found')).toBeVisible()
+ await searchBar.fill('') // clear search bar
+
+ // download as migration file
+ await privateSnippetSection
+ .getByTitle(sqlSnippetName, { exact: true })
+ .click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Download as migration file' }).click()
+ await expect(page.getByText('supabase migration new')).toBeVisible()
+ await page.getByRole('button', { name: 'Close' }).click()
+
+ // delete all files used in this test
+ await deleteSqlSnippet(page, ref, sqlSnippetNameDuplicate)
+ await deleteSqlSnippet(page, ref, sqlSnippetName)
})
})
diff --git a/e2e/studio/playwright.config.ts b/e2e/studio/playwright.config.ts
index 19a86d75589c9..3c1de4c57c4c4 100644
--- a/e2e/studio/playwright.config.ts
+++ b/e2e/studio/playwright.config.ts
@@ -8,11 +8,12 @@ dotenv.config({ path: path.resolve(__dirname, '.env.local') })
const IS_CI = !!process.env.CI
export default defineConfig({
- timeout: 60 * 1000,
+ // timeout: 60 * 1000,
+ timeout: 30000,
testDir: './features',
testMatch: /.*\.spec\.ts/,
forbidOnly: IS_CI,
- retries: IS_CI ? 3 : 1,
+ retries: IS_CI ? 3 : 0,
use: {
baseURL: env.STUDIO_URL,
screenshot: 'off',
diff --git a/e2e/studio/utils/is-cli.ts b/e2e/studio/utils/is-cli.ts
index 6387f4c3a9b12..1d110aeed7a13 100644
--- a/e2e/studio/utils/is-cli.ts
+++ b/e2e/studio/utils/is-cli.ts
@@ -1,8 +1,8 @@
import { env } from '../env.config'
/**
- * Returns true if running in CLI/self-hosted mode (IS_PLATFORM=false),
- * false if running in hosted mode (IS_PLATFORM=true).
+ * Returns true if running in CLI/self-hosted mode (locally),
+ * false if running in hosted mode.
*/
export function isCLI(): boolean {
// IS_PLATFORM=true = hosted mode
diff --git a/e2e/studio/utils/wait-for-response-with-timeout.ts b/e2e/studio/utils/wait-for-response-with-timeout.ts
new file mode 100644
index 0000000000000..0b7b983766baf
--- /dev/null
+++ b/e2e/studio/utils/wait-for-response-with-timeout.ts
@@ -0,0 +1,26 @@
+import { Page, Response } from '@playwright/test'
+
+type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
+
+/**
+ * Waits for a API response for a specific endpoint before continuing the playwright test. If no api response we still continue with the playwright tests.
+ * @param page - Playwright page object
+ * @param basePath - Base path of API endpoint to wait for (e.g. 'pg-meta', 'platform/projects', etc.)
+ * @param ref - Project reference
+ * @param action - Action path of API endpoint to wait for (e.g. 'types', 'triggers', 'content', etc.)
+ * @param options - Optional object which checks more scenarios
+ */
+export async function waitForApiResponseWithTimeout(
+ page: Page,
+ urlMatcher: string | RegExp | ((response: Response) => boolean),
+ timeOutMs?: number
+): Promise {
+ try {
+ return await page.waitForResponse(urlMatcher, { timeout: timeOutMs || 5000 })
+ } catch (error) {
+ if (error.name === 'TimeoutError') {
+ return null
+ }
+ throw error
+ }
+}
diff --git a/e2e/studio/utils/wait-for-response.ts b/e2e/studio/utils/wait-for-response.ts
index ed75056b407e2..5f615d8a4f70c 100644
--- a/e2e/studio/utils/wait-for-response.ts
+++ b/e2e/studio/utils/wait-for-response.ts
@@ -1,15 +1,36 @@
import { Page } from '@playwright/test'
+type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
+
/**
* Waits for a API response for a specific endpoint before continuing the playwright test.
* @param page - Playwright page object
+ * @param basePath - Base path of API endpoint to wait for (e.g. 'pg-meta', 'platform/projects', etc.)
* @param ref - Project reference
- * @param endpoint - The endpoint to wait for (e.g., 'types', 'triggers')
+ * @param action - Action path of API endpoint to wait for (e.g. 'types', 'triggers', 'content', etc.)
+ * @param options - Optional object which checks more scenarios
*/
-export async function waitForApiResponse(page: Page, ref: string, endpoint: string): Promise {
- await page.waitForResponse(
- (response) =>
- response.url().includes(`pg-meta/${ref}/${endpoint}`) ||
- response.url().includes(`pg-meta/default/${endpoint}`)
- )
+export async function waitForApiResponse(
+ page: Page,
+ basePath: string,
+ ref: string,
+ action: string,
+ options?: Options
+): Promise {
+ // regex trims "/" both start and end.
+ const trimmedBasePath = basePath.replace(/^\/+|\/+$/g, '')
+ const httpMethod = options?.method
+
+ await page.waitForResponse((response) => {
+ const urlMatches =
+ response.url().includes(`${trimmedBasePath}/${ref}/${action}`) ||
+ response.url().includes(`${trimmedBasePath}/default/${action}`)
+
+ // checks HTTP method if exists
+ return httpMethod ? urlMatches && response.request().method() === httpMethod : urlMatches
+ })
+}
+
+type Options = {
+ method?: HttpMethod
}
diff --git a/packages/common/posthog-client.ts b/packages/common/posthog-client.ts
index de95e29ab73ef..5e3fc98c46be9 100644
--- a/packages/common/posthog-client.ts
+++ b/packages/common/posthog-client.ts
@@ -9,6 +9,7 @@ interface PostHogClientConfig {
class PostHogClient {
private initialized = false
private pendingGroups: Record = {}
+ private pendingIdentification: { userId: string; properties?: Record } | null = null
private config: PostHogClientConfig
constructor(config: PostHogClientConfig = {}) {
@@ -38,6 +39,12 @@ class PostHogClient {
posthog.group(type, id)
})
this.pendingGroups = {}
+
+ // Apply any pending identification
+ if (this.pendingIdentification) {
+ posthog.identify(this.pendingIdentification.userId, this.pendingIdentification.properties)
+ this.pendingIdentification = null
+ }
},
}
@@ -47,6 +54,14 @@ class PostHogClient {
capturePageView(properties: Record, hasConsent: boolean = true) {
if (!hasConsent || !this.initialized) return
+
+ // Store groups from properties if present (for later group() calls)
+ if (properties.$groups) {
+ Object.entries(properties.$groups).forEach(([type, id]) => {
+ if (id) posthog.group(type, id as string)
+ })
+ }
+
posthog.capture('$pageview', properties)
}
@@ -56,7 +71,13 @@ class PostHogClient {
}
identify(userId: string, properties?: Record, hasConsent: boolean = true) {
- if (!hasConsent || !this.initialized) return
+ if (!hasConsent) return
+
+ if (!this.initialized) {
+ // Queue the identification for when PostHog initializes
+ this.pendingIdentification = { userId, properties }
+ return
+ }
posthog.identify(userId, properties)
}