diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.test.tsx new file mode 100644 index 00000000000..6322f20270d --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { useForm } from 'react-hook-form' +import { Form } from 'ui' +import { describe, expect, it, vi } from 'vitest' + +import type { DestinationPanelSchemaType } from '../DestinationForm.schema' +import { BigQueryFields } from './Fields' +import { customRender } from '@/tests/lib/custom-render' + +const TestForm = ({ serviceAccountKey = '' }: { serviceAccountKey?: string }) => { + const form = useForm({ + defaultValues: { projectId: '', datasetId: '', serviceAccountKey }, + }) + + return ( +
+ + + ) +} + +describe('BigQueryFields', () => { + it('imports a JSON file into an editable service account key field', async () => { + const { container } = customRender() + const contents = '{"type":"service_account"}' + const file = new File([contents], 'service-account.json', { type: 'application/json' }) + Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(contents) }) + + const fileInput = container.querySelector('input[type="file"]') + expect(fileInput).not.toBeNull() + fireEvent.change(fileInput!, { target: { files: [file] } }) + + const textarea = screen.getByPlaceholderText( + '{"type": "service_account", "project_id": "...", ...}' + ) + await waitFor(() => expect(textarea).toHaveValue(contents)) + + fireEvent.change(textarea, { target: { value: `${contents}\n` } }) + expect(textarea).toHaveValue(`${contents}\n`) + expect(textarea).toHaveClass('max-h-[calc(13lh+1rem)]') + }) + + it('imports a dropped JSON file into the service account key field', async () => { + customRender() + const contents = '{"type":"service_account"}' + const file = new File([contents], 'service-account.json', { type: 'application/json' }) + Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(contents) }) + + const textarea = screen.getByPlaceholderText( + '{"type": "service_account", "project_id": "...", ...}' + ) + fireEvent.drop(textarea.closest('div')!, { + dataTransfer: { files: [file] }, + }) + + await waitFor(() => expect(textarea).toHaveValue(contents)) + }) + + it('rejects an oversized file without replacing the existing key', async () => { + const { container } = customRender() + const file = new File(['x'.repeat(5001)], 'service-account.json', { + type: 'application/json', + }) + const readFile = vi.fn() + Object.defineProperty(file, 'text', { value: readFile }) + + const fileInput = container.querySelector('input[type="file"]') + fireEvent.change(fileInput!, { target: { files: [file] } }) + + expect( + await screen.findByText('Service account key must be 5,000 characters or fewer.') + ).toBeInTheDocument() + expect(readFile).not.toHaveBeenCalled() + expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument() + }) + + it('preserves the existing key when the selected file cannot be read', async () => { + const { container } = customRender() + const file = new File(['{}'], 'service-account.json', { type: 'application/json' }) + Object.defineProperty(file, 'text', { + value: vi.fn().mockRejectedValue(new Error('File read failed')), + }) + + const fileInput = container.querySelector('input[type="file"]') + fireEvent.change(fileInput!, { target: { files: [file] } }) + + expect(await screen.findByText('Could not read the selected JSON file.')).toBeInTheDocument() + expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx index 9e7db5d75cf..bb4e4397d95 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx @@ -1,10 +1,54 @@ +import { Upload } from 'lucide-react' +import { useRef, useState, type ChangeEvent, type DragEvent } from 'react' import type { UseFormReturn } from 'react-hook-form' -import { FormControl, FormField, Input, TextArea } from 'ui' +import { Button, cn, FormControl, FormField, Input, TextArea } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants' import type { DestinationPanelSchemaType } from '../DestinationForm.schema' +const MAX_SERVICE_ACCOUNT_KEY_LENGTH = 5000 + +const readServiceAccountFile = async ( + file: File, + form: UseFormReturn, + isCurrentRequest: () => boolean +) => { + if (file.size > MAX_SERVICE_ACCOUNT_KEY_LENGTH) { + if (isCurrentRequest()) { + form.setError('serviceAccountKey', { + message: 'Service account key must be 5,000 characters or fewer.', + }) + } + return + } + + try { + const contents = await file.text() + if (!isCurrentRequest()) return + + if (contents.length > MAX_SERVICE_ACCOUNT_KEY_LENGTH) { + form.setError('serviceAccountKey', { + message: 'Service account key must be 5,000 characters or fewer.', + }) + return + } + + form.setValue('serviceAccountKey', contents, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + form.clearErrors('serviceAccountKey') + } catch { + if (isCurrentRequest()) { + form.setError('serviceAccountKey', { + message: 'Could not read the selected JSON file.', + }) + } + } +} + export const BigQueryFields = ({ form, editMode, @@ -12,6 +56,41 @@ export const BigQueryFields = ({ form: UseFormReturn editMode: boolean }) => { + const serviceAccountFileInputRef = useRef(null) + const fileReadRequestIdRef = useRef(0) + const [isDraggingFile, setIsDraggingFile] = useState(false) + + const handleServiceAccountFile = async (file: File | undefined) => { + if (!file) return + const requestId = ++fileReadRequestIdRef.current + await readServiceAccountFile(file, form, () => requestId === fileReadRequestIdRef.current) + } + + const handleServiceAccountFileInputChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + await handleServiceAccountFile(file) + } + + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDraggingFile(true) + } + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDraggingFile(false) + } + + const handleDrop = async (event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDraggingFile(false) + await handleServiceAccountFile(event.dataTransfer.files?.[0]) + } + return (

BigQuery settings

@@ -57,23 +136,58 @@ export const BigQueryFields = ({ label="Service account key" description={ editMode - ? 'Stored credentials are hidden. Enter new credentials to replace them.' - : 'Service account credentials JSON for authenticating with BigQuery' + ? 'Stored credentials are hidden. Paste or upload new credentials to replace them.' + : 'Paste or upload your service account credentials JSON file for authenticating with BigQuery.' } > - -