diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 26eb00eb48e6..762d96e4d33f 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -82,6 +82,7 @@ "dayjs": "^1.11.19", "dom-to-image-more": "^3.7.2", "dom-to-pdf": "^0.3.2", + "driver.js": "^1.4.0", "echarts": "^5.6.0", "eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings", "fast-glob": "^3.3.2", @@ -29378,6 +29379,11 @@ "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", "license": "Apache-2.0" }, + "node_modules/driver.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", + "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index a1bd6d672a12..4fca111a6fc3 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -164,6 +164,7 @@ "dayjs": "^1.11.19", "dom-to-image-more": "^3.7.2", "dom-to-pdf": "^0.3.2", + "driver.js": "^1.4.0", "echarts": "^5.6.0", "eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings", "fast-glob": "^3.3.2", diff --git a/superset-frontend/src/features/databases/UploadDataModel/ColumnsPreview.tsx b/superset-frontend/src/features/databases/UploadDataModel/ColumnsPreview.tsx index c5c128df2683..cec0849f13c4 100644 --- a/superset-frontend/src/features/databases/UploadDataModel/ColumnsPreview.tsx +++ b/superset-frontend/src/features/databases/UploadDataModel/ColumnsPreview.tsx @@ -25,6 +25,7 @@ import { type TagType, TagsList } from 'src/components'; interface ColumnsPreviewProps { columns: string[]; maxColumnsToShow?: number; + fileTooLarge?: boolean; } export const StyledDivContainer = styled.div` @@ -35,6 +36,7 @@ export const StyledDivContainer = styled.div` const ColumnsPreview: FC = ({ columns, maxColumnsToShow = 4, + fileTooLarge = false, }) => { const tags: TagType[] = columns.map(column => ({ name: column })); @@ -42,7 +44,11 @@ const ColumnsPreview: FC = ({ Columns: {columns.length === 0 ? ( -

{t('Upload file to preview columns')}

+

+ {fileTooLarge + ? t('Preview is not available for files larger than 5MB') + : t('Upload file to preview columns')} +

) : ( )} diff --git a/superset-frontend/src/features/databases/UploadDataModel/DataPreviewModal.test.tsx b/superset-frontend/src/features/databases/UploadDataModel/DataPreviewModal.test.tsx new file mode 100644 index 000000000000..89071f52744c --- /dev/null +++ b/superset-frontend/src/features/databases/UploadDataModel/DataPreviewModal.test.tsx @@ -0,0 +1,179 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + render, + screen, + waitFor, + userEvent, +} from 'spec/helpers/testing-library'; +import { DataPreviewModal } from './DataPreviewModal'; + +// Polyfill File.prototype.text for jsdom (not implemented) +if (typeof File.prototype.text !== 'function') { + File.prototype.text = function (this: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? '')); + reader.onerror = () => reject(reader.error ?? new Error('Read failed')); + reader.readAsText(this); + }); + }; +} + +const defaultProps = { + show: true, + onHide: jest.fn(), + file: null, + type: 'csv' as const, +}; + +function createCSVFile(content: string): File { + return new File([content], 'test.csv', { type: 'text/csv' }); +} + +test('renders modal with Data preview title when show is true', () => { + render(); + + expect(screen.getByText('Data preview')).toBeInTheDocument(); +}); + +test('shows no data to preview when file is null', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('No data to preview')).toBeInTheDocument(); + }); +}); + +test('parses CSV and displays data in table', async () => { + const csvContent = 'name,age,city\nAlice,30,NYC\nBob,25,Boston'; + const file = createCSVFile(csvContent); + + render(); + + await waitFor(() => { + expect( + screen.getByRole('columnheader', { name: 'name' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'age' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'city' }), + ).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Alice' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: '30' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'NYC' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Bob' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: '25' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Boston' })).toBeInTheDocument(); + }); +}); + +test('uses custom delimiter for CSV parsing', async () => { + const csvContent = 'name;age;city\nAlice;30;NYC'; + const file = createCSVFile(csvContent); + + render( + , + ); + + await waitFor(() => { + expect( + screen.getByRole('columnheader', { name: 'name' }), + ).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Alice' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: '30' })).toBeInTheDocument(); + }); +}); + +test('shows error for columnar type', async () => { + const file = createCSVFile('col1\nval1'); + + render(); + + await waitFor(() => { + expect( + screen.getByText('Data preview is not available for Parquet files.'), + ).toBeInTheDocument(); + }); +}); + +test('shows No data to preview for empty CSV', async () => { + const file = createCSVFile(''); + + render(); + + await waitFor(() => { + expect(screen.getByText('No data to preview')).toBeInTheDocument(); + }); +}); + +test('shows Loading while parsing', async () => { + const file = createCSVFile('a,b\n1,2'); + const textSpy = jest.spyOn(File.prototype, 'text'); + textSpy.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve('a,b\n1,2'), 100)), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + textSpy.mockRestore(); +}); + +test('calls onHide when modal close button is clicked', async () => { + const onHide = jest.fn(); + render( + , + ); + + await waitFor(() => { + expect(screen.getByRole('cell', { name: '1' })).toBeInTheDocument(); + }); + + const closeButton = screen.getByTestId('close-modal-btn'); + await userEvent.click(closeButton); + + expect(onHide).toHaveBeenCalledTimes(1); +}); + +test('shows error message when CSV parse fails', async () => { + const file = new File(['invalid'], 'test.csv', { type: 'text/csv' }); + const textSpy = jest.spyOn(File.prototype, 'text'); + textSpy.mockRejectedValueOnce(new Error('Read failed')); + + render(); + + await waitFor(() => { + expect( + screen.getByText(/Read failed|Failed to parse file/), + ).toBeInTheDocument(); + }); + + textSpy.mockRestore(); +}); diff --git a/superset-frontend/src/features/databases/UploadDataModel/DataPreviewModal.tsx b/superset-frontend/src/features/databases/UploadDataModel/DataPreviewModal.tsx new file mode 100644 index 000000000000..05355a26777c --- /dev/null +++ b/superset-frontend/src/features/databases/UploadDataModel/DataPreviewModal.tsx @@ -0,0 +1,186 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { FunctionComponent, useEffect, useState } from 'react'; +import { t } from '@apache-superset/core'; +import { Modal, Table, TableSize } from '@superset-ui/core/components'; +import type { ColumnsType } from 'antd/es/table'; +import type { UploadType } from './uploadDataModalTour'; + +const PREVIEW_ROW_LIMIT = 100; + +interface DataPreviewModalProps { + show: boolean; + onHide: () => void; + file: File | null; + type: UploadType; + delimiter?: string; + sheetName?: string; +} + +async function parseCSV( + file: File, + delimiter: string, +): Promise<{ columns: string[]; data: Record[] }> { + const text = await file.text(); + const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0); + if (lines.length === 0) { + return { columns: [], data: [] }; + } + const headerLine = lines[0]; + const columns = headerLine + .split(delimiter) + .map((c, i) => c.trim() || `col_${i}`); + const dataRows = lines.slice(1, PREVIEW_ROW_LIMIT + 1); + const data = dataRows.map(row => { + const values = row.split(delimiter); + const obj: Record = {}; + columns.forEach((col, i) => { + obj[col] = values[i]?.trim() ?? ''; + }); + return obj; + }); + return { columns, data }; +} + +async function parseExcel( + file: File, + sheetName?: string, +): Promise<{ columns: string[]; data: Record[] }> { + const XLSX = (await import(/* webpackChunkName: "xlsx" */ 'xlsx')).default; + const arrayBuffer = await file.arrayBuffer(); + const workbook = XLSX.read(arrayBuffer, { type: 'array' }); + const sheet = sheetName + ? workbook.Sheets[sheetName] + : workbook.Sheets[workbook.SheetNames[0]]; + if (!sheet) { + return { columns: [], data: [] }; + } + const jsonData = XLSX.utils.sheet_to_json(sheet, { + header: 1, + defval: '', + }) as unknown[][]; + if (jsonData.length === 0) { + return { columns: [], data: [] }; + } + const headerRow = jsonData[0] as (string | number)[]; + const columns = headerRow.map((c, i) => String(c ?? '').trim() || `col_${i}`); + const dataRows = jsonData.slice(1, PREVIEW_ROW_LIMIT + 1) as ( + | string + | number + )[][]; + const data = dataRows.map(row => { + const obj: Record = {}; + columns.forEach((col, i) => { + obj[col] = String(row[i] ?? ''); + }); + return obj; + }); + return { columns, data }; +} + +export const DataPreviewModal: FunctionComponent = ({ + show, + onHide, + file, + type, + delimiter = ',', + sheetName, +}) => { + const [loading, setLoading] = useState(false); + const [columns, setColumns] = useState([]); + const [data, setData] = useState[]>([]); + const [error, setError] = useState(null); + + useEffect(() => { + if (!show || !file) { + setColumns([]); + setData([]); + setError(null); + return; + } + if (type === 'columnar') { + setError(t('Data preview is not available for Parquet files.')); + setColumns([]); + setData([]); + return; + } + setLoading(true); + setError(null); + const parse = + type === 'csv' ? parseCSV(file, delimiter) : parseExcel(file, sheetName); + parse + .then(({ columns: cols, data: parsedData }) => { + setColumns(cols); + setData(parsedData); + }) + .catch(err => { + setError(err?.message || t('Failed to parse file')); + setColumns([]); + setData([]); + }) + .finally(() => setLoading(false)); + }, [show, file, type, delimiter, sheetName]); + + const tableColumns: ColumnsType> = columns.map( + (col, idx) => ({ + title: col, + dataIndex: col, + key: `col_${idx}_${col}`, + ellipsis: true, + width: 150, + }), + ); + + return ( + + {loading && ( +
+ {t('Loading...')} +
+ )} + {error && !loading && ( +
+ {error} +
+ )} + {!loading && !error && columns.length > 0 && ( + `row_${idx}`} + size={TableSize.Small} + defaultPageSize={20} + /> + )} + {!loading && !error && columns.length === 0 && ( +
+ {t('No data to preview')} +
+ )} + + ); +}; diff --git a/superset-frontend/src/features/databases/UploadDataModel/index.tsx b/superset-frontend/src/features/databases/UploadDataModel/index.tsx index 2471c83ec764..6a6b786c1404 100644 --- a/superset-frontend/src/features/databases/UploadDataModel/index.tsx +++ b/superset-frontend/src/features/databases/UploadDataModel/index.tsx @@ -20,6 +20,7 @@ import { FunctionComponent, useEffect, useMemo, + useRef, useState, ReactNode, FC, @@ -31,6 +32,7 @@ import { SupersetTheme } from '@apache-superset/core/ui'; import { Button, Collapse, + Flex, Form, Select, AsyncSelect, @@ -39,6 +41,7 @@ import { Col, Input, InputNumber, + Tooltip, Upload, type UploadChangeParam, type UploadFile, @@ -53,11 +56,14 @@ import { antDModalNoPaddingStyles, antDModalStyles, formStyles, + StyledFileDropzone, StyledFormItem, StyledSwitchContainer, } from './styles'; import ColumnsPreview from './ColumnsPreview'; +import { DataPreviewModal } from './DataPreviewModal'; import StyledFormItemWithTip from './StyledFormItemWithTip'; +import { startUploadDataModalTour } from './uploadDataModalTour'; type UploadType = 'csv' | 'excel' | 'columnar'; @@ -69,8 +75,14 @@ interface UploadDataModalProps { allowedExtensions: string[]; type: UploadType; fileListOverride?: File[]; + showTourOnOpen?: boolean; } +const PREVIEW_FILE_SIZE_LIMIT = 5 * 1024 * 1024; // 5MB + +const isFileTooLargeForPreview = (file: File | undefined | null): boolean => + !!file && file.size > PREVIEW_FILE_SIZE_LIMIT; + const CSVSpecificFields = [ 'delimiter', 'skip_initial_space', @@ -218,10 +230,12 @@ const UploadDataModal: FunctionComponent = ({ allowedExtensions, type = 'csv', fileListOverride, + showTourOnOpen = false, }) => { const [form] = Form.useForm(); const [currentDatabaseId, setCurrentDatabaseId] = useState(0); const [fileList, setFileList] = useState([]); + const fileListRef = useRef([]); const [columns, setColumns] = useState([]); const [sheetNames, setSheetNames] = useState([]); const [sheetsColumnNames, setSheetsColumnNames] = useState< @@ -235,6 +249,8 @@ const UploadDataModal: FunctionComponent = ({ const [previewUploadedFile, setPreviewUploadedFile] = useState(true); const [fileLoading, setFileLoading] = useState(false); const [activeKey, setActiveKey] = useState('general'); + const [dataPreviewModalOpen, setDataPreviewModalOpen] = + useState(false); const createTypeToEndpointMap = (databaseId: number) => `/api/v1/database/${databaseId}/upload/`; @@ -316,7 +332,9 @@ const UploadDataModal: FunctionComponent = ({ const clearModal = () => { setFileList([]); + fileListRef.current = []; setColumns([]); + setDataPreviewModalOpen(false); setCurrentSchema(''); setCurrentDatabaseId(0); setSheetNames([]); @@ -491,7 +509,9 @@ const UploadDataModal: FunctionComponent = ({ }; const onRemoveFile = (removedFile: UploadFile) => { - setFileList(fileList.filter(file => file.uid !== removedFile.uid)); + const newFileList = fileList.filter(file => file.uid !== removedFile.uid); + setFileList(newFileList); + fileListRef.current = newFileList; setColumns([]); setSheetNames([]); form.setFieldsValue({ sheet_name: undefined }); @@ -515,30 +535,44 @@ const UploadDataModal: FunctionComponent = ({ })); const onChangeFile = async (info: UploadChangeParam) => { - setFileList([ + const newFileList = [ { ...info.file, - status: 'done', + status: 'done' as const, }, - ]); + ]; + setFileList(newFileList); + fileListRef.current = newFileList; + if (info.file.originFileObj) { + form.validateFields(['file']).catch(() => {}); + } if (!previewUploadedFile) { return; } + if (isFileTooLargeForPreview(info.file.originFileObj)) { + setColumns([]); + return; + } await loadFileMetadata(info.file.originFileObj); }; useEffect(() => { if (fileListOverride?.length) { - setFileList( - fileListOverride.map(file => ({ - uid: file.name, - name: file.name, - originFileObj: file as UploadFile['originFileObj'], - status: 'done' as const, - })), - ); - if (previewUploadedFile) { + const newFileList = fileListOverride.map(file => ({ + uid: file.name, + name: file.name, + originFileObj: file as UploadFile['originFileObj'], + status: 'done' as const, + })); + setFileList(newFileList); + fileListRef.current = newFileList; + if ( + previewUploadedFile && + !isFileTooLargeForPreview(fileListOverride[0]) + ) { loadFileMetadata(fileListOverride[0]).then(r => r); + } else if (isFileTooLargeForPreview(fileListOverride[0])) { + setColumns([]); } } }, [fileListOverride, previewUploadedFile]); @@ -552,6 +586,9 @@ const UploadDataModal: FunctionComponent = ({ if (!previewUploadedFile) { return; } + if (isFileTooLargeForPreview(fileList[0].originFileObj)) { + return; + } loadFileMetadata(fileList[0].originFileObj).then(r => r); } }, [delimiter]); @@ -563,11 +600,49 @@ const UploadDataModal: FunctionComponent = ({ } }, [show]); + // Start tour when modal opens (with delay for DOM to render) + useEffect(() => { + if (!show || !showTourOnOpen) { + return undefined; + } + const timer = setTimeout(() => { + startUploadDataModalTour(type); + }, 300); + return () => clearTimeout(timer); + }, [show, showTourOnOpen, type]); + + const modalFooter = ( + <> + + + + ); + const validateUpload = (_: any, value: string) => { - if (fileList.length === 0) { + const currentFileList = fileListRef.current; + if (currentFileList.length === 0) { return Promise.reject(t('Uploading a file is required')); } - if (!validateUploadFileExtension(fileList[0], allowedExtensions)) { + if (!validateUploadFileExtension(currentFileList[0], allowedExtensions)) { return Promise.reject( t( 'Upload a file with a valid extension. Valid: [%s]', @@ -593,391 +668,379 @@ const UploadDataModal: FunctionComponent = ({ const UploadTitle: FC = () => { const title = uploadTitles[type] || t('Upload'); - return ; + return ( + + + + - - {}} - > - - - - - - - - - - - - - {previewUploadedFile && ( - - - - - - )} - - - - - - - - - - - - - - - - - - - - - - {isFieldATypeSpecificField('delimiter', type) && ( - - - - + + {}} + disabled={fileLoading} + showUploadList={{ + showRemoveIcon: true, + }} + > +

+ +

+

+ {t('Drag and drop your file here')} +

+

+ {t('or')}{' '} + {' '} + — {allowedExtensionsToAccept[type]} +

+
+
- )} - - ), - }, - { - key: 'file-settings', - label: ( - {t('File settings')} - ), - children: ( - <> - -
- - - )} - {isFieldATypeSpecificField('decimal_character', type) && ( - - - - - - )} - {isFieldATypeSpecificField('null_values', type) && ( - - - - - )} - - ), - }, - { - key: 'columns', - label: {t('Columns')}, - children: ( - <> - - - - + + + + )} + {isFieldATypeSpecificField('sheet_name', type) && ( + + + + {}} /> - )} - - - - - - - - {currentDataframeIndex && - isFieldATypeSpecificField('index_column', type) && ( + {isFieldATypeSpecificField('column_dates', type) && ( + + + + + + + + )} + {isFieldATypeSpecificField('null_values', type) && ( - + ({ + value: column, + label: column, + }))} + allowClear + allowNewOptions + /> + + + + )} + {currentDataframeIndex && ( + + - - - - - - + - ), - }, - ] - : []), - ]} - /> - - + )} + + ), + }, + ...(isFieldATypeSpecificField('header_row', type) && + isFieldATypeSpecificField('rows_to_read', type) && + isFieldATypeSpecificField('skip_rows', type) + ? [ + { + key: 'rows', + label: ( + {t('Rows')} + ), + children: ( + + + + + + + + + + + + + + + + + + ), + }, + ] + : []), + ]} + /> + + + setDataPreviewModalOpen(false)} + file={ + isFileTooLargeForPreview(fileList[0]?.originFileObj) + ? null + : (fileList[0]?.originFileObj ?? null) + } + type={type} + delimiter={formValues.delimiter ?? ','} + sheetName={formValues.sheet_name} + /> + ); }; diff --git a/superset-frontend/src/features/databases/UploadDataModel/styles.ts b/superset-frontend/src/features/databases/UploadDataModel/styles.ts index bb841d615166..5eb5eb53d1d7 100644 --- a/superset-frontend/src/features/databases/UploadDataModel/styles.ts +++ b/superset-frontend/src/features/databases/UploadDataModel/styles.ts @@ -21,6 +21,53 @@ import { css, styled, SupersetTheme } from '@apache-superset/core/ui'; const MODAL_BODY_HEIGHT = 180.5; +export const StyledFileDropzone = styled.div` + ${({ theme }) => css` + .ant-upload-drag { + border-radius: ${theme.borderRadius}px; + border: 2px dashed ${theme.colorPrimaryBorder}; + background: ${theme.colorPrimaryBg}; + padding: ${theme.sizeUnit * 5}px ${theme.sizeUnit * 4}px; + transition: + border-color 0.2s ease, + background-color 0.2s ease; + } + + .ant-upload-drag:hover { + border-color: ${theme.colorPrimary}; + background: ${theme.colorPrimaryBgHover}; + } + + .ant-upload-drag-icon { + margin-bottom: ${theme.sizeUnit * 2}px; + } + + .ant-upload-drag-icon .anticon { + font-size: ${theme.sizeUnit * 8}px; + color: ${theme.colorPrimary}; + } + + .ant-upload-text { + font-size: ${theme.fontSize}px; + color: ${theme.colorTextHeading}; + margin-bottom: ${theme.sizeUnit}px; + } + + .ant-upload-hint { + font-size: ${theme.fontSizeSM}px; + color: ${theme.colorTextDescription}; + } + + .ant-upload-drag .ant-upload-btn { + padding: 0; + } + + .ant-upload-list { + margin-top: ${theme.sizeUnit * 2}px; + } + `} +`; + export const StyledFormItem = styled(FormItem)` ${({ theme }) => css` flex: 1; diff --git a/superset-frontend/src/features/databases/UploadDataModel/uploadDataModalTour.test.ts b/superset-frontend/src/features/databases/UploadDataModel/uploadDataModalTour.test.ts new file mode 100644 index 000000000000..1432a5c8dc36 --- /dev/null +++ b/superset-frontend/src/features/databases/UploadDataModel/uploadDataModalTour.test.ts @@ -0,0 +1,127 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { startUploadDataModalTour } from './uploadDataModalTour'; + +const mockDrive = jest.fn(); +const mockDriver = jest.fn<{ drive: jest.Mock }, [Record]>( + () => ({ + drive: mockDrive, + }), +); + +jest.mock('driver.js', () => ({ + driver: (config: Record) => mockDriver(config), +})); + +// Suppress CSS import in tests +jest.mock('driver.js/dist/driver.css', () => ({})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +function getDriverConfig(): Record { + expect(mockDriver).toHaveBeenCalled(); + const call = mockDriver.mock.calls[0] as unknown[]; + return call[0] as Record; +} + +test('startUploadDataModalTour calls driver with correct config for CSV', () => { + startUploadDataModalTour('csv'); + + expect(mockDriver).toHaveBeenCalledTimes(1); + const config = getDriverConfig(); + expect(config.animate).toBe(false); + expect(config.showProgress).toBe(true); + expect(config.showButtons).toEqual(['next', 'previous', 'close']); + const steps = config.steps as Array<{ element: string }>; + expect(steps).toHaveLength(5); + expect(steps[0].element).toBe( + '[data-tour="upload-modal-csv"] [data-tour="upload-file-dropzone-csv"]', + ); + expect(steps[4].element).toBe( + '[data-tour="upload-modal-csv"] [data-tour="upload-submit-button-csv"]', + ); + expect(mockDrive).toHaveBeenCalledTimes(1); +}); + +test('startUploadDataModalTour calls driver with correct selectors for Excel', () => { + startUploadDataModalTour('excel'); + + const config = getDriverConfig(); + const steps = config.steps as Array<{ element: string }>; + expect(steps[0].element).toBe( + '[data-tour="upload-modal-excel"] [data-tour="upload-file-dropzone-excel"]', + ); + expect(steps[4].element).toBe( + '[data-tour="upload-modal-excel"] [data-tour="upload-submit-button-excel"]', + ); +}); + +test('startUploadDataModalTour calls driver with correct selectors for columnar', () => { + startUploadDataModalTour('columnar'); + + const config = getDriverConfig(); + const steps = config.steps as Array<{ element: string }>; + expect(steps[0].element).toBe( + '[data-tour="upload-modal-columnar"] [data-tour="upload-file-dropzone-columnar"]', + ); + expect(steps[4].element).toBe( + '[data-tour="upload-modal-columnar"] [data-tour="upload-submit-button-columnar"]', + ); +}); + +test('startUploadDataModalTour steps have expected popover structure', () => { + startUploadDataModalTour('csv'); + + const config = getDriverConfig(); + const expectedTitles = [ + 'Upload your file', + 'Preview columns', + 'Select database', + 'Table name', + 'Upload', + ]; + const steps = config.steps as Array<{ + popover: { title: string; description?: string }; + }>; + steps.forEach((step, idx) => { + expect(step.popover).toBeDefined(); + expect(step.popover.title).toBe(expectedTitles[idx]); + expect(step.popover.description).toBeDefined(); + expect(typeof step.popover.description).toBe('string'); + }); +}); + +test('startUploadDataModalTour includes all five tour steps with correct element targets', () => { + startUploadDataModalTour('csv'); + + const config = getDriverConfig(); + const expectedTargets = [ + 'upload-file-dropzone-csv', + 'upload-preview-csv', + 'upload-database-csv', + 'upload-table-name-csv', + 'upload-submit-button-csv', + ]; + const steps = config.steps as Array<{ element: string }>; + steps.forEach((step, idx) => { + expect(step.element).toContain(expectedTargets[idx]); + }); +}); diff --git a/superset-frontend/src/features/databases/UploadDataModel/uploadDataModalTour.ts b/superset-frontend/src/features/databases/UploadDataModel/uploadDataModalTour.ts new file mode 100644 index 000000000000..5d31d24f274e --- /dev/null +++ b/superset-frontend/src/features/databases/UploadDataModel/uploadDataModalTour.ts @@ -0,0 +1,99 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { driver, type DriveStep } from 'driver.js'; +import 'driver.js/dist/driver.css'; +import { t } from '@apache-superset/core'; + +export type UploadType = 'csv' | 'excel' | 'columnar'; + +function getTourSteps(type: UploadType): DriveStep[] { + const scope = `[data-tour="upload-modal-${type}"]`; + return [ + { + element: `${scope} [data-tour="upload-file-dropzone-${type}"]`, + popover: { + title: t('Upload your file'), + description: t( + 'Drag and drop your file here or click Select to browse. Supported formats: CSV, Excel, or Columnar (Parquet).', + ), + side: 'bottom', + align: 'start', + }, + }, + { + element: `${scope} [data-tour="upload-preview-${type}"]`, + popover: { + title: t('Preview columns'), + description: t( + 'Toggle to preview the columns from your uploaded file. The column names will appear here once a file is selected.', + ), + side: 'bottom', + align: 'start', + }, + }, + { + element: `${scope} [data-tour="upload-database-${type}"]`, + popover: { + title: t('Select database'), + description: t( + 'Choose the database where you want to store the uploaded data.', + ), + side: 'bottom', + align: 'start', + }, + }, + { + element: `${scope} [data-tour="upload-table-name-${type}"]`, + popover: { + title: t('Table name'), + description: t( + 'Enter a name for the new table that will be created from your uploaded file.', + ), + side: 'bottom', + align: 'start', + }, + }, + { + element: `${scope} [data-tour="upload-submit-button-${type}"]`, + popover: { + title: t('Upload'), + description: t( + 'Click Upload to import your data. Make sure you have selected a file, database, and entered a table name.', + ), + side: 'top', + align: 'center', + }, + }, + ]; +} + +export function startUploadDataModalTour(type: UploadType): void { + const driverObj = driver({ + animate: false, + showProgress: true, + showButtons: ['next', 'previous', 'close'], + progressText: t('{{current}} of {{total}}'), + nextBtnText: t('Next'), + prevBtnText: t('Previous'), + doneBtnText: t('Done'), + steps: getTourSteps(type), + }); + + driverObj.drive(); +}