mirror of
https://github.com/apache/superset.git
synced 2026-04-28 20:44:24 +00:00
Compare commits
1 Commits
upgrade-sq
...
file-handl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7083d09777 |
BIN
superset-frontend/src/assets/images/pwa/icon-192.png
Normal file
BIN
superset-frontend/src/assets/images/pwa/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
BIN
superset-frontend/src/assets/images/pwa/icon-512.png
Normal file
BIN
superset-frontend/src/assets/images/pwa/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
superset-frontend/src/assets/images/pwa/screenshot-narrow.png
Normal file
BIN
superset-frontend/src/assets/images/pwa/screenshot-narrow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
superset-frontend/src/assets/images/pwa/screenshot-wide.png
Normal file
BIN
superset-frontend/src/assets/images/pwa/screenshot-wide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
@@ -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) {
|
||||
|
||||
368
superset-frontend/src/pages/FileHandler/index.test.tsx
Normal file
368
superset-frontend/src/pages/FileHandler/index.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
138
superset-frontend/src/pages/FileHandler/index.tsx
Normal file
138
superset-frontend/src/pages/FileHandler/index.tsx
Normal file
@@ -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);
|
||||
65
superset-frontend/src/pwa-manifest.json
Normal file
65
superset-frontend/src/pwa-manifest.json
Normal file
@@ -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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
superset-frontend/src/service-worker.ts
Normal file
38
superset-frontend/src/service-worker.ts
Normal file
@@ -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 {};
|
||||
@@ -171,6 +171,10 @@ const UserRegistrations = lazy(
|
||||
),
|
||||
);
|
||||
|
||||
const FileHandler = lazy(
|
||||
() => import(/* webpackChunkName: "FileHandler" */ 'src/pages/FileHandler'),
|
||||
);
|
||||
|
||||
type Routes = {
|
||||
path: string;
|
||||
Component: ComponentType;
|
||||
@@ -199,6 +203,10 @@ export const routes: Routes = [
|
||||
path: '/superset/welcome/',
|
||||
Component: Home,
|
||||
},
|
||||
{
|
||||
path: '/superset/file-handler',
|
||||
Component: FileHandler,
|
||||
},
|
||||
{
|
||||
path: '/dashboard/list/',
|
||||
Component: DashboardList,
|
||||
|
||||
@@ -71,20 +71,30 @@ const isDevServer = process.argv[1]?.includes('webpack-dev-server') ?? false;
|
||||
// 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
|
||||
|
||||
@@ -634,7 +634,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:
|
||||
@@ -650,6 +650,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
|
||||
|
||||
27
superset/static/service-worker.js
Normal file
27
superset/static/service-worker.js
Normal file
@@ -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());
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",))
|
||||
|
||||
Reference in New Issue
Block a user