diff --git a/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionLogsSelectionRender.tsx b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionLogsSelectionRender.tsx
index 9d371f162d7..fb5a925767b 100644
--- a/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionLogsSelectionRender.tsx
+++ b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionLogsSelectionRender.tsx
@@ -1,34 +1,11 @@
import dayjs from 'dayjs'
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
-import { jsonSyntaxHighlight, SeverityFormatter } from '../LogsFormatters'
+import { jsonSyntaxHighlight, SelectionDetailedRow, SeverityFormatter } from '../LogsFormatters'
const FunctionLogsSelectionRender = ({ log }: any) => {
const timestamp = dayjs(log.timestamp / 1000)
const metadata = log.metadata[0]
- const DetailedRow = ({
- label,
- value,
- code,
- }: {
- label: string
- value: string | React.ReactNode
- code?: boolean
- }) => {
- return (
-
@@ -39,11 +16,15 @@ const FunctionLogsSelectionRender = ({ log }: any) => {
Metadata
diff --git a/studio/components/interfaces/Settings/Logs/Logs.constants.ts b/studio/components/interfaces/Settings/Logs/Logs.constants.ts
index 85dc9ba6f89..6c50b7cd2d2 100644
--- a/studio/components/interfaces/Settings/Logs/Logs.constants.ts
+++ b/studio/components/interfaces/Settings/Logs/Logs.constants.ts
@@ -215,7 +215,9 @@ export const SQL_FILTER_TEMPLATES: any = {
'method.get': `request.method = 'GET'`,
'method.post': `request.method = 'POST'`,
- 'method.del': `request.method = 'DEL'`,
+ 'method.put': `request.method = 'PUT'`,
+ 'method.patch': `request.method = 'PATCH'`,
+ 'method.delete': `request.method = 'DELETE'`,
'method.options': `request.method = 'OPTIONS'`,
},
function_edge_logs: {
@@ -373,11 +375,26 @@ export const FILTER_OPTIONS: FilterTableSet = {
label: 'OPTIONS',
description: '',
},
+ {
+ key: 'put',
+ label: 'PUT',
+ description: '',
+ },
{
key: 'post',
label: 'POST',
description: '',
},
+ {
+ key: 'patch',
+ label: 'PATCH',
+ description: '',
+ },
+ {
+ key: 'delete',
+ label: 'DELETE',
+ description: '',
+ },
],
},
},
diff --git a/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx b/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx
index 82156ba5284..87ed2f6f855 100644
--- a/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx
+++ b/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx
@@ -8,11 +8,41 @@ import { IconAlertCircle, IconInfo } from 'ui'
import dayjs from 'dayjs'
import React from 'react'
import { isUnixMicro, unixMicroToIsoTimestamp } from '.'
+import CopyButton from 'components/ui/CopyButton'
export const RowLayout: React.FC = ({ children }) => (
{children}
)
+export const SelectionDetailedRow = ({
+ label,
+ value,
+ valueRender,
+}: {
+ label: string
+ value: string
+ valueRender?: React.ReactNode
+}) => {
+ return (
+
+ {label}
+
+ {valueRender ?? value}
+
+
+ {''}
+
+
+ )
+}
+
+// used for column renderers
export const TextFormatter: React.FC<{ className?: string; value: string }> = ({
value,
className,
diff --git a/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js b/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js
index dd0cdba43d2..28888b61c59 100644
--- a/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js
+++ b/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js
@@ -1,11 +1,12 @@
import Split from 'react-split'
import Editor from '@monaco-editor/react'
import DataGrid from '@supabase/react-data-grid'
+import classNames from 'classnames'
import { CSVLink } from 'react-csv'
import { debounce } from 'lodash'
import { observer } from 'mobx-react-lite'
import { useEffect, useRef, useState } from 'react'
-import { Button, Dropdown, IconChevronDown } from 'ui'
+import { Button, Dropdown, IconCheck, IconChevronDown, IconClipboard } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useKeyboardShortcuts, useStore, useWindowDimensions, checkPermissions } from 'hooks'
@@ -306,21 +307,38 @@ const UtilityTabResults = observer(() => {
const Results = ({ results }) => {
const [cellPosition, setCellPosition] = useState(undefined)
+ const [copiedCell, setCopiedCell] = useState(undefined)
useKeyboardShortcuts(
{
'Command+c': (event) => {
event.stopPropagation()
- onCopyCell()
+ onCopySelectedCell()
},
'Control+c': (event) => {
event.stopPropagation()
- onCopyCell()
+ onCopySelectedCell()
},
},
['INPUT', 'TEXTAREA']
)
+ useEffect(() => {
+ let timeoutId = 0
+
+ if (copiedCell) {
+ timeoutId = setTimeout(() => {
+ setCopiedCell(undefined)
+ }, 1000)
+ }
+
+ return () => {
+ // we need to clear previous timeout to prevent checkmark flickering
+ // when clicking `Copy` button multiple times in a short time
+ timeoutId && clearTimeout(timeoutId)
+ }
+ }, [copiedCell])
+
if (results?.error) {
return (
@@ -336,8 +354,33 @@ const Results = ({ results }) => {
)
}
- const formatter = (column, row) => {
- return
{JSON.stringify(row[column])}
+ const handleCopyClick = (column, row, rowIndex) => {
+ copyToClipboard(formatClipboardValue(row[column]), () => {
+ setCopiedCell(`${column},${rowIndex}`)
+ })
+ }
+
+ const formatter = (column, row, rowIndex) => {
+ const isCopied = copiedCell === `${column},${rowIndex}`
+
+ return (
+
+ {JSON.stringify(row[column])}
+
+ {row[column] && (
+ : }
+ onClick={() => handleCopyClick(column, row, rowIndex)}
+ className={classNames(
+ 'mx-1 group-hover:block group-hover:opacity-50 hover:opacity-100',
+ !isCopied && 'hidden'
+ )}
+ title="Copy"
+ />
+ )}
+
+ )
}
const columnRender = (name) => {
return
{name}
@@ -345,7 +388,7 @@ const Results = ({ results }) => {
const columns = Object.keys(results[0]).map((key) => ({
key,
name: key,
- formatter: ({ row }) => formatter(key, row),
+ formatter: ({ row }) => formatter(key, row, results.indexOf(row)),
headerRenderer: () => columnRender(key),
resizable: true,
width: 120,
@@ -355,7 +398,7 @@ const Results = ({ results }) => {
setCellPosition(position)
}
- function onCopyCell() {
+ function onCopySelectedCell() {
if (columns && cellPosition) {
const { idx, rowIdx } = cellPosition
const column = columns[idx]
diff --git a/studio/components/ui/CopyButton.tsx b/studio/components/ui/CopyButton.tsx
new file mode 100644
index 00000000000..690f3399d7e
--- /dev/null
+++ b/studio/components/ui/CopyButton.tsx
@@ -0,0 +1,47 @@
+import { Button, ButtonProps } from 'ui'
+import { copyToClipboard } from 'lib/helpers'
+import { IconClipboard } from 'ui'
+import { useEffect, useState } from 'react'
+
+export interface CopyButtonProps extends ButtonProps {
+ text: string
+ // used for text-less feedback
+ bounceIconOnCopy?: boolean
+}
+const CopyButton: React.FC
= ({
+ text,
+ children,
+ onClick,
+ bounceIconOnCopy,
+ ...props
+}) => {
+ const [showCopied, setShowCopied] = useState(false)
+
+ useEffect(() => {
+ if (!showCopied) return
+ const timer = setTimeout(() => setShowCopied(false), 2000)
+ return () => clearTimeout(timer)
+ }, [showCopied])
+
+ return (
+ {
+ setShowCopied(true)
+ copyToClipboard(text)
+ onClick?.(e)
+ }}
+ icon={
+
+ }
+ {...props}
+ >
+ {children ?? (showCopied ? 'Copied' : 'Copy')}
+
+ )
+}
+export default CopyButton
diff --git a/studio/hooks/analytics/useLogsPreview.tsx b/studio/hooks/analytics/useLogsPreview.tsx
index 6927a579c91..278a625cf5e 100644
--- a/studio/hooks/analytics/useLogsPreview.tsx
+++ b/studio/hooks/analytics/useLogsPreview.tsx
@@ -14,7 +14,7 @@ import {
LogsTableName,
PREVIEWER_DATEPICKER_HELPERS,
} from 'components/interfaces/Settings/Logs'
-import { Dispatch, SetStateAction, useEffect, useState } from 'react'
+import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'
import useSWR from 'swr'
import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite'
import { API_URL } from 'lib/constants'
@@ -47,18 +47,21 @@ function useLogsPreview(
const [latestRefresh, setLatestRefresh] = useState(new Date().toISOString())
const [filters, setFilters] = useState({ ...filterOverride })
+ const isFirstRender = useRef(true)
const [params, setParams] = useState({
project: projectRef,
- sql: '',
+ sql: genDefaultQuery(table, filters),
iso_timestamp_start: defaultHelper.calcFrom(),
iso_timestamp_end: defaultHelper.calcTo(),
})
useEffect(() => {
- if (filters !== {}) {
- refresh()
+ if (isFirstRender.current) {
+ isFirstRender.current = false
+ return
}
+ refresh()
}, [JSON.stringify(filters)])
// handle url generation for log pagination
diff --git a/studio/tests/components/CopyButton.test.tsx b/studio/tests/components/CopyButton.test.tsx
new file mode 100644
index 00000000000..d8db3fc667e
--- /dev/null
+++ b/studio/tests/components/CopyButton.test.tsx
@@ -0,0 +1,12 @@
+import { screen } from '@testing-library/dom'
+import userEvent from '@testing-library/user-event'
+import CopyButton from 'components/ui/CopyButton'
+import { render } from 'tests/helpers'
+
+test('shows copied text', async () => {
+ const callback = jest.fn()
+ render( )
+ userEvent.click(await screen.findByText('Copy'))
+ await screen.findByText('Copied')
+ expect(callback).toBeCalled()
+})
diff --git a/studio/tests/pages/projects/LogEventChart.test.js b/studio/tests/pages/projects/LogEventChart.test.js
index 58f3862ada7..b9ea349a3ee 100644
--- a/studio/tests/pages/projects/LogEventChart.test.js
+++ b/studio/tests/pages/projects/LogEventChart.test.js
@@ -34,5 +34,5 @@ test('renders chart', async () => {
// )
// userEvent.click(paths[0])
// expect(mock).toBeCalledTimes(1)
- await screen.findByText('Events')
+ await screen.findByText(/Logs \/ Time/)
})
diff --git a/studio/tests/pages/projects/LogTable.test.js b/studio/tests/pages/projects/LogTable.test.js
index 804bfdbe685..94624e0b891 100644
--- a/studio/tests/pages/projects/LogTable.test.js
+++ b/studio/tests/pages/projects/LogTable.test.js
@@ -23,6 +23,10 @@ test('can display log data', async () => {
userEvent.click(row)
await screen.findByText(/my_key/)
await screen.findByText(/something_value/)
+
+ // render copy button
+ userEvent.click(await screen.findByText(/Copy/))
+ await screen.findByText(/Copied/)
})
test('dedupes log lines with exact id', async () => {
diff --git a/studio/tests/pages/projects/LogsPreviewer.test.js b/studio/tests/pages/projects/LogsPreviewer.test.js
index 058abca0a6c..eae71bf3abf 100644
--- a/studio/tests/pages/projects/LogsPreviewer.test.js
+++ b/studio/tests/pages/projects/LogsPreviewer.test.js
@@ -309,7 +309,7 @@ test('log event chart hide', async () => {
return { result: [] }
})
render( )
- await screen.findByText('Events')
+ await screen.findByText(/Logs \/ Time/)
const toggle = await screen.findByText(/Chart/)
userEvent.click(toggle)
await expect(screen.findByText('Events')).rejects.toThrow()