Files
claude[bot] aaa1b8c0df fix(ui): fail copyToClipboard when the Clipboard API is unavailable (#50641)
<!-- ccr-slack-attribution -->
_Requested by **Pam Chia** · [Slack
thread](https://supabase.slack.com/archives/C076KTY11DF/p1789979683276099?thread_ts=1789953317.522459&cid=C076KTY11DF)_

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix.

## What is the current behavior?

**Before:** `copyToClipboard` writes text with
`navigator.clipboard?.writeText(text)`. When `navigator.clipboard` is
undefined — an insecure context, such as self-hosted Studio served over
plain http or Studio reached over a LAN IP, where `ClipboardItem` is
also undefined so the Safari branch above is skipped — the optional
chaining makes the whole expression resolve to `undefined`. Nothing
throws, so the `catch` never runs and the success callback on the next
line runs anyway. The caller is told the copy succeeded: the UI shows
its "Copied!" confirmation state and the copy-tracking telemetry event
fires as a successful copy, even though nothing reached the clipboard.
That contradicts the documented contract of those events, which are
defined as firing only when the clipboard write succeeded.

## What is the new behavior?

**After:** the missing-clipboard case fails instead of silently
succeeding. The callback does not run, no copy event fires, and the
error toast that the function already shows on failure (`Unable to copy
to clipboard`) is what the user sees. Every working path behaves exactly
as before, including the Safari `ClipboardItem` branch, which is
untouched.

## Additional context

How: throw when `navigator.clipboard` is missing, inside the `try` block
that already exists, so the case lands in the existing `catch` and its
error toast rather than falling through to the success path. The
now-redundant optional chaining on the write is dropped. One case was
added to the existing shared clipboard util tests asserting that the
callback does not fire and the error toast shows when the Clipboard API
is unavailable; it fails on `master` and passes with this change.

Linear:
[GROWTH-1261](https://linear.app/supabase/issue/GROWTH-1261/clipboard-copy-helper-reports-success-when-the-clipboard-api-is)

---
🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01QdJB22CngN3tpc7Kfdpram

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-21 19:54:37 +08:00

192 lines
5.6 KiB
TypeScript

import { copyToClipboard } from 'ui'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import {
formatFilterURLParams,
formatSortURLParams,
handleCellKeyDown,
} from '@/components/grid/SupabaseGrid.utils'
const { toastError, toastSuccess } = vi.hoisted(() => ({
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('sonner', () => ({
toast: {
error: toastError,
success: toastSuccess,
},
}))
// Sort URL syntax: `column:order`
describe('SupabaseGrid.utils: formatSortURLParams', () => {
test('should return an array of sort options based on URL params', () => {
const mockInput = ['id:asc', 'name:desc']
const output = formatSortURLParams(
{ name: 'fakeTable', columns: [{ name: 'id' }, { name: 'name' }] },
mockInput
)
expect(output).toStrictEqual([
{ table: 'fakeTable', column: 'id', ascending: true },
{ table: 'fakeTable', column: 'name', ascending: false },
])
})
test('should reject any malformed sort options based on URL params', () => {
const mockInput = ['id', 'name:asc', ':asc']
const output = formatSortURLParams(
{ name: 'fakeTable', columns: [{ name: 'name' }] },
mockInput
)
expect(output).toStrictEqual([
{
table: 'fakeTable',
column: 'name',
ascending: true,
},
])
})
test('should reject any sort options with non-existent columns based on URL params', () => {
const mockInput = ['name2:asc']
const output = formatSortURLParams(
{ name: 'fakeTable', columns: [{ name: 'name' }] },
mockInput
)
expect(output).toStrictEqual([])
})
})
// Filter URL syntax: `column:operatorAbbreviation:value`
describe('SupabaseGrid.utils: formatFilterURLParams', () => {
test('should return an array of filter options based on URL params', () => {
const mockInput = ['id:gte:20', 'id:lte:40']
const output = formatFilterURLParams(mockInput)
expect(output).toHaveLength(2)
expect(output[0]).toStrictEqual({
column: 'id',
operator: '>=',
value: '20',
})
expect(output[1]).toStrictEqual({
column: 'id',
operator: '<=',
value: '40',
})
})
test('should format filters for timestamps correctly', () => {
const mockInput = ['created_at:gte:2022-05-30 03:00:00']
const output = formatFilterURLParams(mockInput)
expect(output[0]).toStrictEqual({
column: 'created_at',
operator: '>=',
value: '2022-05-30 03:00:00',
})
})
test('should reject any malformed filter options based on URL params', () => {
const mockInput = ['id', ':gte', ':50', 'id:eq:10']
const output = formatFilterURLParams(mockInput)
expect(output).toHaveLength(1)
})
test('should reject any filter options with unrecognized operator', () => {
const mockInput = ['id:meme:40', 'name:eq:town']
const output = formatFilterURLParams(mockInput)
expect(output).toHaveLength(1)
})
test('should allow filter options to have empty value based on URL params', () => {
const mockInput = ['id:ilike:']
const output = formatFilterURLParams(mockInput)
expect(output).toHaveLength(1)
expect(output[0]).toStrictEqual({
column: 'id',
operator: '~~*',
value: '',
})
})
})
describe('SupabaseGrid.utils: handleCellKeyDown', () => {
beforeEach(() => {
toastError.mockReset()
toastSuccess.mockReset()
vi.unstubAllGlobals()
vi.spyOn(window.document, 'hasFocus').mockReturnValue(true)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
test('should copy the selected cell value when Meta+C is pressed', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', {
clipboard: { writeText },
})
const args = {
mode: 'SELECT',
column: { key: 'name' },
row: { name: 'hello from safari' },
rowIdx: 0,
selectCell: vi.fn(),
} as unknown as Parameters<typeof handleCellKeyDown>[0]
const event = {
key: 'C',
metaKey: true,
ctrlKey: false,
altKey: false,
nativeEvent: new KeyboardEvent('keydown', { key: 'C', metaKey: true }),
preventDefault: vi.fn(),
preventGridDefault: vi.fn(),
} as unknown as Parameters<typeof handleCellKeyDown>[1]
handleCellKeyDown(args, event)
await vi.waitFor(() => {
expect(writeText).toHaveBeenCalledWith('hello from safari')
})
expect(event.preventDefault).toHaveBeenCalled()
expect(event.preventGridDefault).toHaveBeenCalled()
await vi.waitFor(() => {
expect(toastSuccess).toHaveBeenCalledWith('Copied cell value to clipboard')
})
})
})
describe('shared clipboard util', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.spyOn(window.document, 'hasFocus').mockReturnValue(true)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
test('should invoke the callback after writing text to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
const onCopy = vi.fn()
vi.stubGlobal('navigator', {
clipboard: { writeText },
})
await copyToClipboard('hello from safari', onCopy)
expect(writeText).toHaveBeenCalledWith('hello from safari')
expect(onCopy).toHaveBeenCalled()
})
test('should not invoke the callback when the Clipboard API is unavailable', async () => {
const onCopy = vi.fn()
vi.stubGlobal('navigator', {})
await copyToClipboard('no clipboard here', onCopy)
expect(onCopy).not.toHaveBeenCalled()
expect(toastError).toHaveBeenCalledWith('Unable to copy to clipboard')
})
})