This is an automated email from the ASF dual-hosted git repository. beto pushed a commit to branch hackathon-12-2025 in repository https://gitbox.apache.org/repos/asf/superset.git
commit 3e38931a8c892f3b8042081773a75d831de0a4df Author: Beto Dealmeida <[email protected]> AuthorDate: Fri Dec 19 09:47:32 2025 -0500 feat: file handler for CSV/XSL --- .../src/assets/images/pwa/icon-192.png | Bin 0 -> 9097 bytes .../src/assets/images/pwa/icon-512.png | Bin 0 -> 25535 bytes .../src/assets/images/pwa/screenshot-narrow.png | Bin 0 -> 100187 bytes .../src/assets/images/pwa/screenshot-wide.png | Bin 0 -> 253025 bytes .../features/databases/UploadDataModel/index.tsx | 20 +- .../src/pages/FileHandler/index.test.tsx | 368 +++++++++++++++++++++ superset-frontend/src/pages/FileHandler/index.tsx | 138 ++++++++ superset-frontend/src/pwa-manifest.json | 65 ++++ superset-frontend/src/service-worker.ts | 38 +++ superset-frontend/src/views/routes.tsx | 8 + superset-frontend/webpack.config.js | 45 ++- superset/initialization/__init__.py | 10 +- superset/static/service-worker.js | 27 ++ superset/templates/superset/spa.html | 15 +- superset/views/core.py | 15 + 15 files changed, 734 insertions(+), 15 deletions(-) diff --git a/superset-frontend/src/assets/images/pwa/icon-192.png b/superset-frontend/src/assets/images/pwa/icon-192.png new file mode 100644 index 0000000000..bf280f8561 Binary files /dev/null and b/superset-frontend/src/assets/images/pwa/icon-192.png differ diff --git a/superset-frontend/src/assets/images/pwa/icon-512.png b/superset-frontend/src/assets/images/pwa/icon-512.png new file mode 100644 index 0000000000..e36419b43e Binary files /dev/null and b/superset-frontend/src/assets/images/pwa/icon-512.png differ diff --git a/superset-frontend/src/assets/images/pwa/screenshot-narrow.png b/superset-frontend/src/assets/images/pwa/screenshot-narrow.png new file mode 100644 index 0000000000..f601594e96 Binary files /dev/null and b/superset-frontend/src/assets/images/pwa/screenshot-narrow.png differ diff --git a/superset-frontend/src/assets/images/pwa/screenshot-wide.png b/superset-frontend/src/assets/images/pwa/screenshot-wide.png new file mode 100644 index 0000000000..c2a3731a0e Binary files /dev/null and b/superset-frontend/src/assets/images/pwa/screenshot-wide.png differ diff --git a/superset-frontend/src/features/databases/UploadDataModel/index.tsx b/superset-frontend/src/features/databases/UploadDataModel/index.tsx index 67122178dc..081c3998e4 100644 --- a/superset-frontend/src/features/databases/UploadDataModel/index.tsx +++ b/superset-frontend/src/features/databases/UploadDataModel/index.tsx @@ -67,6 +67,7 @@ interface UploadDataModalProps { show: boolean; allowedExtensions: string[]; type: UploadType; + fileListOverride?: File[]; } const CSVSpecificFields = [ @@ -215,6 +216,7 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({ show, allowedExtensions, type = 'csv', + fileListOverride, }) => { const [form] = Form.useForm(); const [currentDatabaseId, setCurrentDatabaseId] = useState<number>(0); @@ -524,10 +526,26 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({ 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) { + loadFileMetadata(fileListOverride[0]).then(r => r); + } + } + }, [fileListOverride, previewUploadedFile]); + useEffect(() => { if ( columns.length > 0 && - fileList[0].originFileObj && + fileList.length > 0 && fileList[0].originFileObj instanceof File ) { if (!previewUploadedFile) { diff --git a/superset-frontend/src/pages/FileHandler/index.test.tsx b/superset-frontend/src/pages/FileHandler/index.test.tsx new file mode 100644 index 0000000000..471186c9b4 --- /dev/null +++ b/superset-frontend/src/pages/FileHandler/index.test.tsx @@ -0,0 +1,368 @@ +/** + * 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 { ComponentType } from 'react'; +import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import { MemoryRouter, Route } from 'react-router-dom'; +import FileHandler from './index'; + +const mockAddDangerToast = jest.fn(); +const mockAddSuccessToast = jest.fn(); +const mockHistoryPush = jest.fn(); + +type ToastInjectedProps = { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; +}; + +// Mock the withToasts HOC +jest.mock('src/components/MessageToasts/withToasts', () => ({ + __esModule: true, + default: (Component: ComponentType<ToastInjectedProps>) => + function MockedWithToasts(props: Record<string, unknown>) { + return ( + <Component + {...props} + addDangerToast={mockAddDangerToast} + addSuccessToast={mockAddSuccessToast} + /> + ); + }, +})); + +interface UploadDataModalProps { + show: boolean; + onHide: () => void; + type: string; + allowedExtensions: string[]; + fileListOverride?: File[]; +} + +// Mock the UploadDataModal +jest.mock('src/features/databases/UploadDataModel', () => ({ + __esModule: true, + default: ({ + show, + onHide, + type, + allowedExtensions, + fileListOverride, + }: UploadDataModalProps) => ( + <div data-test="upload-modal"> + <div data-test="modal-show">{show.toString()}</div> + <div data-test="modal-type">{type}</div> + <div data-test="modal-extensions">{allowedExtensions.join(',')}</div> + <div data-test="modal-file">{fileListOverride?.[0]?.name ?? ''}</div> + <button onClick={onHide}>Close</button> + </div> + ), +})); + +// Mock react-router-dom's useHistory +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush, + }), +})); + +// Mock the File API +type MockFileHandle = { + kind: 'file'; + name: string; + getFile: () => Promise<File>; + isSameEntry: () => Promise<boolean>; + queryPermission: () => Promise<PermissionState>; + requestPermission: () => Promise<PermissionState>; +}; + +const createMockFileHandle = (fileName: string): MockFileHandle => ({ + kind: 'file', + name: fileName, + getFile: async () => new File(['test'], fileName), + isSameEntry: async () => false, + queryPermission: async () => 'granted', + requestPermission: async () => 'granted', +}); + +type LaunchQueue = { + setConsumer: ( + consumer: (params: { files?: MockFileHandle[] }) => void, + ) => void; +}; + +const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => { + let savedConsumer: + | ((params: { files?: MockFileHandle[] }) => void | Promise<void>) + | null = null; + (window as unknown as Window & { launchQueue: LaunchQueue }).launchQueue = { + setConsumer: (consumer: (params: { files?: MockFileHandle[] }) => void) => { + savedConsumer = consumer; + if (fileHandle) { + setTimeout(() => { + consumer({ + files: [fileHandle], + }); + }, 0); + } + }, + }; + return { + triggerConsumer: async (params: { files?: MockFileHandle[] }) => { + await savedConsumer?.(params); + }, + }; +}; + +beforeEach(() => { + jest.clearAllMocks(); + delete (window as any).launchQueue; +}); + +test('shows error when launchQueue is not supported', async () => { + render( + <MemoryRouter initialEntries={['/superset/file-handler']}> + <Route path="/superset/file-handler"> + <FileHandler /> + </Route> + </MemoryRouter>, + { useRedux: true }, + ); + + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + 'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.', + ); + expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/'); + }); +}); + +test('redirects when no files are provided', async () => { + const { triggerConsumer } = setupLaunchQueue(); + + render( + <MemoryRouter initialEntries={['/superset/file-handler']}> + <Route path="/superset/file-handler"> + <FileHandler /> + </Route> + </MemoryRouter>, + { useRedux: true }, + ); + + // Trigger the consumer with no files + await triggerConsumer({ files: [] }); + + await waitFor(() => { + expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/'); + }); +}); + +test('handles CSV file correctly', async () => { + const fileHandle = createMockFileHandle('test.csv'); + setupLaunchQueue(fileHandle); + + render( + <MemoryRouter initialEntries={['/superset/file-handler']}> + <Route path="/superset/file-handler"> + <FileHandler /> + </Route> + </MemoryRouter>, + { useRedux: true }, + ); + + const modal = await screen.findByTestId('upload-modal'); + expect(modal).toBeInTheDocument(); + expect(screen.getByTestId('modal-show')).toHaveTextContent('true'); + expect(screen.getByTestId('modal-type')).toHaveTextContent('csv'); + expect(screen.getByTestId('modal-extensions')).toHaveTextContent('csv'); + expect(screen.getByTestId('modal-file')).toHaveTextContent('test.csv'); +}); + +test('handles Excel (.xls) file correctly', async () => { + const fileHandle = createMockFileHandle('test.xls'); + setupLaunchQueue(fileHandle); + + render( + <MemoryRouter initialEntries={['/superset/file-handler']}> + <Route path="/superset/file-handler"> + <FileHandler /> + </Route> + </MemoryRouter>, + { useRedux: true }, + ); + + const modal = await screen.findByTestId('upload-modal'); + expect(modal).toBeInTheDocument(); + expect(screen.getByTestId('modal-type')).toHaveTextContent('excel'); + expect(screen.getByTestId('modal-extensions')).toHaveTextContent('xls,xlsx'); +}); + +test('handles Excel (.xlsx) file correctly', async () => { + const fileHandle = createMockFileHandle('test.xlsx'); + setupLaunchQueue(fileHandle); + + render( + <MemoryRouter initialEntries={['/superset/file-handler']}> + <Route path="/superset/file-handler"> + <FileHandler /> + </Route> + </MemoryRouter>, + { useRedux: true }, + ); + + const modal = await screen.findByTestId('upload-modal'); + expect(modal).toBeInTheDocument(); + expect(screen.getByTestId('modal-type')).toHaveTextContent('excel'); + expect(screen.getByTestId('modal-extensions')).toHaveTextContent('xls,xlsx'); +}); + +test('handles Parquet file correctly', async () => { + const fileHandle = createMockFileHandle('test.parquet'); + setupLaunchQueue(fileHandle); + + render( + <MemoryRouter initialEntries={['/superset/file-handler']}> + <Route path="/superset/file-handler"> + <FileHandler /> + </Route> + </MemoryRouter>, + { useRedux: true }, + ); + + const modal = await screen.findByTestId('upload-modal'); + expect(modal).toBeInTheDocument(); + expect(screen.getByTestId('modal-type')).toHaveTextContent('columnar'); + expect(screen.getByTestId('modal-extensions')).toHaveTextContent('parquet'); +}); + +test('shows error for unsupported file type', async () => { + const { triggerConsumer } = setupLaunchQueue(); + + render( + <MemoryRouter initialEntries={['/superset/file-handler']}> + <Route path="/superset/file-handler"> + <FileHandler /> + </Route> + </MemoryRouter>, + { useRedux: true }, + ); + + // Trigger with unsupported file + const fileHandle = createMockFileHandle('test.pdf'); + await triggerConsumer({ files: [fileHandle] }); + + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + 'Unsupported file type. Please use CSV, Excel, or Columnar files.', + ); + expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/'); + }); +}); + +test('handles file with uppercase extension', async () => { + const fileHandle = createMockFileHandle('test.CSV'); + setupLaunchQueue(fileHandle); + + render( + <MemoryRouter initialEntries={['/superset/file-handler']}> + <Route path="/superset/file-handler"> + <FileHandler /> + </Route> + </MemoryRouter>, + { useRedux: true }, + ); + + const modal = await screen.findByTestId('upload-modal'); + expect(modal).toBeInTheDocument(); + expect(screen.getByTestId('modal-type')).toHaveTextContent('csv'); +}); + +test('handles errors during file processing', async () => { + const { triggerConsumer } = setupLaunchQueue(); + + render( + <MemoryRouter initialEntries={['/superset/file-handler']}> + <Route path="/superset/file-handler"> + <FileHandler /> + </Route> + </MemoryRouter>, + { useRedux: true }, + ); + + // Trigger with a file handle that throws an error + const errorFileHandle: MockFileHandle = { + kind: 'file', + name: 'error.csv', + getFile: async () => { + throw new Error('File access denied'); + }, + isSameEntry: async () => false, + queryPermission: async () => 'granted', + requestPermission: async () => 'granted', + }; + + await triggerConsumer({ files: [errorFileHandle] }); + + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + 'Failed to open file. Please try again.', + ); + expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/'); + }); +}); + +test('modal close redirects to welcome page', async () => { + const fileHandle = createMockFileHandle('test.csv'); + setupLaunchQueue(fileHandle); + + render( + <MemoryRouter initialEntries={['/superset/file-handler']}> + <Route path="/superset/file-handler"> + <FileHandler /> + </Route> + </MemoryRouter>, + { useRedux: true }, + ); + + const modal = await screen.findByTestId('upload-modal'); + expect(modal).toBeInTheDocument(); + + // Click the close button in the mocked modal + const closeButton = screen.getByRole('button', { name: 'Close' }); + closeButton.click(); + + await waitFor(() => { + expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/'); + }); +}); + +test('shows loading state while waiting for file', () => { + setupLaunchQueue(); + + render( + <MemoryRouter initialEntries={['/superset/file-handler']}> + <Route path="/superset/file-handler"> + <FileHandler /> + </Route> + </MemoryRouter>, + { useRedux: true }, + ); + + // Should show loading initially before file is processed + expect(screen.getByRole('status')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/pages/FileHandler/index.tsx b/superset-frontend/src/pages/FileHandler/index.tsx new file mode 100644 index 0000000000..68ed035769 --- /dev/null +++ b/superset-frontend/src/pages/FileHandler/index.tsx @@ -0,0 +1,138 @@ +/** + * 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 { useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { t } from '@superset-ui/core'; +import { Loading } from '@superset-ui/core/components'; +import UploadDataModal from 'src/features/databases/UploadDataModel'; +import withToasts from 'src/components/MessageToasts/withToasts'; + +interface FileLaunchParams { + readonly files?: readonly FileSystemFileHandle[]; +} + +interface LaunchQueue { + setConsumer: (consumer: (params: FileLaunchParams) => void) => void; +} + +interface WindowWithLaunchQueue extends Window { + launchQueue?: LaunchQueue; +} + +interface FileHandlerProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; +} + +const FileHandler = ({ addDangerToast, addSuccessToast }: FileHandlerProps) => { + const history = useHistory(); + const [uploadFile, setUploadFile] = useState<File | null>(null); + const [uploadType, setUploadType] = useState< + 'csv' | 'excel' | 'columnar' | null + >(null); + const [showModal, setShowModal] = useState(false); + const [allowedExtensions, setAllowedExtensions] = useState<string[]>([]); + + useEffect(() => { + const handleFileLaunch = async () => { + const { launchQueue } = window as WindowWithLaunchQueue; + + if (!launchQueue) { + addDangerToast( + t( + 'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.', + ), + ); + history.push('/superset/welcome/'); + return; + } + + launchQueue.setConsumer(async (launchParams: FileLaunchParams) => { + if (!launchParams.files || launchParams.files.length === 0) { + history.push('/superset/welcome/'); + return; + } + + try { + const fileHandle = launchParams.files[0]; + const file = await fileHandle.getFile(); + const fileName = file.name.toLowerCase(); + + let type: 'csv' | 'excel' | 'columnar' | null = null; + let extensions: string[] = []; + + if (fileName.endsWith('.csv')) { + type = 'csv'; + extensions = ['csv']; + } else if (fileName.endsWith('.xls') || fileName.endsWith('.xlsx')) { + type = 'excel'; + extensions = ['xls', 'xlsx']; + } else if (fileName.endsWith('.parquet')) { + type = 'columnar'; + extensions = ['parquet']; + } else { + addDangerToast( + t( + 'Unsupported file type. Please use CSV, Excel, or Columnar files.', + ), + ); + history.push('/superset/welcome/'); + return; + } + + setUploadFile(file); + setUploadType(type); + setAllowedExtensions(extensions); + setShowModal(true); + } catch (error) { + console.error('Error handling file launch:', error); + addDangerToast(t('Failed to open file. Please try again.')); + history.push('/superset/welcome/'); + } + }); + }; + + handleFileLaunch(); + }, [history, addDangerToast]); + + const handleModalClose = () => { + setShowModal(false); + setUploadFile(null); + setUploadType(null); + history.push('/superset/welcome/'); + }; + + if (!uploadFile || !uploadType) { + return <Loading />; + } + + return ( + <UploadDataModal + show={showModal} + onHide={handleModalClose} + fileListOverride={[uploadFile]} + allowedExtensions={allowedExtensions} + type={uploadType} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + /> + ); +}; + +export default withToasts(FileHandler); diff --git a/superset-frontend/src/pwa-manifest.json b/superset-frontend/src/pwa-manifest.json new file mode 100644 index 0000000000..fcd8f2213e --- /dev/null +++ b/superset-frontend/src/pwa-manifest.json @@ -0,0 +1,65 @@ +{ + "name": "Apache Superset", + "short_name": "Superset", + "description": "Modern data exploration and visualization platform", + "start_url": "/superset/welcome/", + "scope": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#20a7c9", + "icons": [ + { + "src": "/static/assets/images/pwa/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/assets/images/pwa/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/assets/images/pwa/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/static/assets/images/pwa/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "screenshots": [ + { + "src": "/static/assets/images/pwa/screenshot-wide.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide", + "label": "Apache Superset Dashboard" + }, + { + "src": "/static/assets/images/pwa/screenshot-narrow.png", + "sizes": "540x720", + "type": "image/png", + "form_factor": "narrow", + "label": "Apache Superset Mobile View" + } + ], + "file_handlers": [ + { + "action": "/superset/file-handler", + "accept": { + "text/csv": [".csv"], + "application/vnd.ms-excel": [".xls"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [ + ".xlsx" + ], + "application/vnd.apache.parquet": [".parquet"] + } + } + ] +} diff --git a/superset-frontend/src/service-worker.ts b/superset-frontend/src/service-worker.ts new file mode 100644 index 0000000000..9feff3c014 --- /dev/null +++ b/superset-frontend/src/service-worker.ts @@ -0,0 +1,38 @@ +/** + * 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. + */ + +// Service Worker types (declared locally to avoid polluting global scope) +declare const self: { + skipWaiting(): Promise<void>; + clients: { claim(): Promise<void> }; + addEventListener( + type: 'install' | 'activate', + listener: (event: { waitUntil(promise: Promise<unknown>): void }) => void, + ): void; +}; + +self.addEventListener('install', event => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', event => { + event.waitUntil(self.clients.claim()); +}); + +export {}; diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 0cb0ba9473..87d38f7680 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -178,6 +178,10 @@ const UserRegistrations = lazy( ), ); +const FileHandler = lazy( + () => import(/* webpackChunkName: "FileHandler" */ 'src/pages/FileHandler'), +); + type Routes = { path: string; Component: ComponentType; @@ -206,6 +210,10 @@ export const routes: Routes = [ path: '/superset/welcome/', Component: Home, }, + { + path: '/superset/file-handler', + Component: FileHandler, + }, { path: '/dashboard/list/', Component: DashboardList, diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index 36522596d7..f9e5f5e1c5 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -71,20 +71,30 @@ const isDevServer = process.argv[1].includes('webpack-dev-server'); // TypeScript checker memory limit (in MB) const TYPESCRIPT_MEMORY_LIMIT = 4096; +const defaultEntryFilename = isDevMode + ? '[name].[contenthash:8].entry.js' + : nameChunks + ? '[name].[chunkhash].entry.js' + : '[name].[chunkhash].entry.js'; + +const defaultChunkFilename = isDevMode + ? '[name].[contenthash:8].chunk.js' + : nameChunks + ? '[name].[chunkhash].chunk.js' + : '[chunkhash].chunk.js'; + const output = { path: BUILD_DIR, publicPath: '/static/assets/', + filename: pathData => + pathData.chunk?.name === 'service-worker' + ? '../service-worker.js' + : defaultEntryFilename, + chunkFilename: pathData => + pathData.chunk?.name === 'service-worker' + ? '../service-worker.js' + : defaultChunkFilename, }; -if (isDevMode) { - output.filename = '[name].[contenthash:8].entry.js'; - output.chunkFilename = '[name].[contenthash:8].chunk.js'; -} else if (nameChunks) { - output.filename = '[name].[chunkhash].entry.js'; - output.chunkFilename = '[name].[chunkhash].chunk.js'; -} else { - output.filename = '[name].[chunkhash].entry.js'; - output.chunkFilename = '[chunkhash].chunk.js'; -} if (!isDevMode) { output.clean = true; @@ -139,7 +149,11 @@ const plugins = [ }), new CopyPlugin({ - patterns: ['package.json', { from: 'src/assets/images', to: 'images' }], + patterns: [ + 'package.json', + { from: 'src/assets/images', to: 'images' }, + { from: 'src/pwa-manifest.json', to: 'pwa-manifest.json' }, + ], }), // static pages @@ -184,7 +198,13 @@ if (!process.env.CI) { // Add React Refresh plugin for development mode if (isDevMode) { - plugins.push(new ReactRefreshWebpackPlugin()); + plugins.push( + new ReactRefreshWebpackPlugin({ + // Exclude service worker from React Refresh - it runs in a worker context + // without DOM/window and doesn't need HMR + exclude: /service-worker/, + }), + ); } if (!isDevMode) { @@ -300,6 +320,7 @@ const config = { menu: addPreamble('src/views/menu.tsx'), spa: addPreamble('/src/views/index.tsx'), embedded: addPreamble('/src/embedded/index.tsx'), + 'service-worker': path.join(APP_DIR, 'src/service-worker.ts'), }, cache: { type: 'filesystem', // Enable filesystem caching diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 40801225b3..2255655d0a 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -648,7 +648,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods def register_request_handlers(self) -> None: """Register app-level request handlers""" - from flask import Response + from flask import request, Response @self.superset_app.after_request def apply_http_headers(response: Response) -> Response: @@ -664,6 +664,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods for k, v in self.superset_app.config["DEFAULT_HTTP_HEADERS"].items(): if k not in response.headers: response.headers[k] = v + + # Allow service worker to control the root scope for PWA file handling + if ( + request.path.endswith("service-worker.js") + and "Service-Worker-Allowed" not in response.headers + ): + response.headers["Service-Worker-Allowed"] = "/" + return response @self.superset_app.after_request diff --git a/superset/static/service-worker.js b/superset/static/service-worker.js new file mode 100644 index 0000000000..43cb14a489 --- /dev/null +++ b/superset/static/service-worker.js @@ -0,0 +1,27 @@ +/** + * 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. + */ + +// Minimal service worker for PWA file handling support +self.addEventListener('install', event => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', event => { + event.waitUntil(self.clients.claim()); +}); diff --git a/superset/templates/superset/spa.html b/superset/templates/superset/spa.html index 8ce70725c2..5951d88b4a 100644 --- a/superset/templates/superset/spa.html +++ b/superset/templates/superset/spa.html @@ -28,7 +28,9 @@ {% endblock %} </title> - {% block head_meta %}{% endblock %} + {% block head_meta %} + <link rel="manifest" href="{{ assets_prefix }}/static/assets/pwa-manifest.json?v=4"> + {% endblock %} <style> body { @@ -73,6 +75,17 @@ /> {% block head_js %} {% include "head_custom_extra.html" %} + <script nonce="{{ macros.get_nonce() }}"> + if ('serviceWorker' in navigator) { + window.addEventListener('load', function() { + navigator.serviceWorker + .register('{{ assets_prefix }}/static/service-worker.js') + .catch(function(err) { + console.error('Service Worker registration failed:', err); + }); + }); + } + </script> {% endblock %} </head> diff --git a/superset/views/core.py b/superset/views/core.py index ced2078b14..845ba163bc 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -908,6 +908,21 @@ class Superset(BaseSupersetView): return self.render_app_template(extra_bootstrap_data=payload) + @has_access + @event_logger.log_this + @expose("/file-handler") + def file_handler(self) -> FlaskResponse: + """File handler page for PWA file handling""" + if not g.user or not get_user_id(): + return redirect_to_login() + + payload = { + "user": bootstrap_user_data(g.user, include_perms=True), + "common": common_bootstrap_payload(), + } + + return self.render_app_template(extra_bootstrap_data=payload) + @has_access @event_logger.log_this @expose("/sqllab/history/", methods=("GET",))
