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",))

Reply via email to