feat(mapper): secure xlsx upload and dataset selection
This commit is contained in:
@@ -483,6 +483,55 @@ interface ValidationRunQueryParams {
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
// #region uploadFile [C:2] [TYPE Function] [SEMANTICS api, upload, file, multipart]
|
||||
// @BRIEF Upload a file via multipart/form-data to the given endpoint.
|
||||
// @PRE endpoint is a non-empty string path.
|
||||
// @PRE file is a File object.
|
||||
// @POST Returns Promise<T> with parsed JSON.
|
||||
// @SIDE_EFFECT Sends HTTP POST with multipart/form-data.
|
||||
// @RATIONALE Uses native fetch because the existing fetchApi/postApi/requestApi wrappers
|
||||
// always set Content-Type: application/json, which breaks multipart uploads (the browser
|
||||
// must auto-set Content-Type with the boundary). This is the ONLY exception to the
|
||||
// "never native fetch" invariant.
|
||||
// @RELATION CALLED_BY -> [MapperTool]
|
||||
async function uploadFile<T = unknown>(endpoint: string, file: File): Promise<T> {
|
||||
const _start = performance.now();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
log('ApiClient', 'REASON', 'Upload file', { endpoint, filename: file.name, size: file.size });
|
||||
const headers: Record<string, string> = {};
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const tid = getTraceId();
|
||||
if (tid && tid !== 'no-trace') {
|
||||
headers['X-Trace-ID'] = tid;
|
||||
}
|
||||
}
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
_captureTraceId(response);
|
||||
const data = await response.json() as T;
|
||||
log('ApiClient', 'REFLECT', 'Upload completed', {
|
||||
endpoint, filename: file.name, status: response.status,
|
||||
elapsed_ms: Math.round(performance.now() - _start),
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
log('ApiClient', 'EXPLORE', 'Upload failed', { endpoint, filename: file.name }, apiError?.message || 'unknown');
|
||||
notifyApiError(apiError);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// #endregion uploadFile
|
||||
|
||||
|
||||
// #region ApiRegistry [C:3] [TYPE Block] [SEMANTICS api, endpoints, registry]
|
||||
// @BRIEF Named endpoint registry — maps backend API paths to typed frontend methods.
|
||||
// @LAYER API
|
||||
@@ -491,7 +540,8 @@ interface ValidationRunQueryParams {
|
||||
// @RELATION DEPENDS_ON -> [deleteApi]
|
||||
// @RELATION DEPENDS_ON -> [requestApi]
|
||||
// @RELATION DEPENDS_ON -> [fetchApiBlob]
|
||||
// @INVARIANT Every method delegates to fetchApi/postApi/deleteApi/requestApi — never native fetch.
|
||||
// @RELATION DEPENDS_ON -> [uploadFile]
|
||||
// @INVARIANT Every method delegates to fetchApi/postApi/deleteApi/requestApi/uploadFile — never native fetch.
|
||||
// @RATIONALE The registry pattern keeps endpoint paths in one place and eliminates path-string duplication across components.
|
||||
export const api = {
|
||||
fetchApi: fetchApi as <T = unknown>(endpoint: string, options?: FetchOptions) => Promise<T>,
|
||||
@@ -499,6 +549,7 @@ export const api = {
|
||||
deleteApi: deleteApi as <T = unknown>(endpoint: string, options?: FetchOptions) => Promise<T>,
|
||||
requestApi: requestApi as <T = unknown>(endpoint: string, method?: string, body?: unknown, requestOptions?: FetchOptions) => Promise<T>,
|
||||
fetchApiBlob: fetchApiBlob as (endpoint: string, options?: FetchOptions) => Promise<Blob>,
|
||||
uploadFile: uploadFile as <T = unknown>(endpoint: string, file: File) => Promise<T>,
|
||||
|
||||
// ═══ Tasks ════════════════════════════════════════════════════
|
||||
|
||||
@@ -1163,7 +1214,7 @@ export const api = {
|
||||
// #endregion ApiRegistry
|
||||
// #endregion ApiModule
|
||||
|
||||
export { fetchApi, postApi, deleteApi, requestApi };
|
||||
export { fetchApi, postApi, deleteApi, requestApi, uploadFile };
|
||||
export const getPlugins = api.getPlugins;
|
||||
export const getTasks = api.getTasks;
|
||||
export const getTask = api.getTask;
|
||||
|
||||
@@ -1016,6 +1016,128 @@ describe('ApiModule — registry methods', () => {
|
||||
expect(await api.getStorageFileBlob('/some/file.csv')).toBe(blob);
|
||||
expect(fetch).toHaveBeenCalledWith('/api/storage/file?path=%2Fsome%2Ffile.csv', expect.any(Object));
|
||||
});
|
||||
|
||||
// #region uploadFileTests [C:2] [TYPE Test] [SEMANTICS test,api,upload,file]
|
||||
// @BRIEF Verify uploadFile sends FormData with auth headers and handles responses.
|
||||
describe('uploadFile', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
vi.stubGlobal('window', {
|
||||
location: { protocol: 'http:', host: 'localhost:5173' },
|
||||
});
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('sends POST with FormData and auth headers, returns JSON on success', async () => {
|
||||
localStorage.setItem('auth_token', 'test-token');
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ upload_id: 'upload-42' }),
|
||||
} as Response);
|
||||
|
||||
const { uploadFile } = await import('$lib/api.js');
|
||||
const file = new File(['test'], 'mapping.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
const result = await uploadFile('/tools/mapper/upload-xlsx', file);
|
||||
|
||||
expect(result).toEqual({ upload_id: 'upload-42' });
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/tools/mapper/upload-xlsx',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-token',
|
||||
}),
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
);
|
||||
// Verify FormData contains the file
|
||||
const callArgs = vi.mocked(fetch).mock.calls[0];
|
||||
const formData = callArgs[1].body as FormData;
|
||||
expect(formData.get('file')).toBe(file);
|
||||
});
|
||||
|
||||
it('omits Content-Type header to let browser set multipart boundary', async () => {
|
||||
localStorage.setItem('auth_token', 'tok');
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: () => Promise.resolve({ path: '/tmp/f.xlsx' }),
|
||||
} as Response);
|
||||
|
||||
const { uploadFile } = await import('$lib/api.js');
|
||||
const file = new File(['data'], 'f.xlsx');
|
||||
await uploadFile('/tools/mapper/upload-xlsx', file);
|
||||
|
||||
const headers = vi.mocked(fetch).mock.calls[0][1].headers as Record<string, string>;
|
||||
// Should NOT have Content-Type (browser sets it automatically for FormData)
|
||||
expect(headers['Content-Type']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws ApiError on non-ok response and dispatches error toast', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ detail: 'Only .xlsx files are accepted' }),
|
||||
} as Response);
|
||||
|
||||
const { addToast } = await import('$lib/toasts.svelte.js');
|
||||
addToast.mockClear();
|
||||
const { uploadFile } = await import('$lib/api.js');
|
||||
const file = new File(['data'], 'bad.csv');
|
||||
|
||||
await expect(uploadFile('/tools/mapper/upload-xlsx', file)).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: 'Only .xlsx files are accepted',
|
||||
});
|
||||
expect(addToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes X-Trace-ID header when trace ID is set', async () => {
|
||||
const { getTraceId } = await import('$lib/cot-logger.js');
|
||||
vi.mocked(getTraceId).mockReturnValue('my-trace-42');
|
||||
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: () => Promise.resolve({ path: '/tmp/t.xlsx' }),
|
||||
} as Response);
|
||||
|
||||
const { uploadFile } = await import('$lib/api.js');
|
||||
const file = new File(['data'], 't.xlsx');
|
||||
await uploadFile('/tools/mapper/upload-xlsx', file);
|
||||
|
||||
const headers = vi.mocked(fetch).mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(headers['X-Trace-ID']).toBe('my-trace-42');
|
||||
});
|
||||
|
||||
it('works without auth token (no Authorization header)', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: () => Promise.resolve({ path: '/tmp/t.xlsx' }),
|
||||
} as Response);
|
||||
|
||||
const { uploadFile } = await import('$lib/api.js');
|
||||
const file = new File(['data'], 't.xlsx');
|
||||
await uploadFile('/tools/mapper/upload-xlsx', file);
|
||||
|
||||
const headers = vi.mocked(fetch).mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uploadFile is available on api.uploadFile', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: () => Promise.resolve({ path: '/tmp/t.xlsx' }),
|
||||
} as Response);
|
||||
|
||||
const { api } = await import('$lib/api.js');
|
||||
expect(typeof api.uploadFile).toBe('function');
|
||||
});
|
||||
});
|
||||
// #endregion uploadFileTests
|
||||
it('updateGlobalSettings sends PATCH', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(await _okJson({ success: true }));
|
||||
const { api } = await import('$lib/api.js');
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
<!-- #region MapperTool [C:3] [TYPE Component] [SEMANTICS mapper, dataset, column, mapping, sqllab] -->
|
||||
<!-- #region MapperTool [C:3] [TYPE Component] [SEMANTICS mapper, dataset, column, mapping, sqllab, excel, upload] -->
|
||||
<!-- @ingroup Components -->
|
||||
<!-- @BRIEF UI component for mapping dataset column verbose names using Superset SQL Lab or Excel files. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @UX_STATE Idle -> Form fields visible, action buttons enabled. -->
|
||||
<!-- @UX_STATE Loading -> Action buttons disabled, spinner active on clicked button. -->
|
||||
<!-- @UX_STATE Uploading -> File upload in progress, upload button disabled. -->
|
||||
<!-- @UX_STATE DragOver -> File being dragged over drop zone, visual highlight on border + icon. -->
|
||||
<!-- @UX_STATE Uploaded -> File uploaded, filename shown, remove button visible. -->
|
||||
<!-- @UX_STATE Error -> Validation errors shown inline, toast notifications displayed. -->
|
||||
<!-- @UX_FEEDBACK Toast notifications on success/error. -->
|
||||
<!-- @UX_RECOVERY Fix validation errors, retry operation. -->
|
||||
<!--
|
||||
@SEMANTICS: mapper, tool, dataset, sqllab, excel
|
||||
<!-- @UX_FEEDBACK Toast notifications on upload success/error, task start/error, doc apply success/error. -->
|
||||
<!-- @UX_RECOVERY Fix validation errors, retry file upload, retry mapper operation. -->
|
||||
<!-- @UX_REACTIVITY Props -> $props(), LocalState -> let vars (Svelte 5 legacy bind: compat). -->
|
||||
<!-- @SEMANTICS: mapper, tool, dataset, sqllab, excel, upload, file
|
||||
@PURPOSE: UI component for mapping dataset column verbose names using Superset SQL Lab or Excel files.
|
||||
@LAYER UI
|
||||
@RELATION USES -> [ToolsService]
|
||||
@RELATION DEPENDS_ON -> [api.uploadFile]
|
||||
@RELATION DEPENDS_ON -> [api.requestApi]
|
||||
@RELATION DEPENDS_ON -> [DatasetSearchCombobox]
|
||||
-->
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount } from 'svelte';
|
||||
import { runTask } from '../../../services/toolsService.js';
|
||||
import { api } from '$lib/api';
|
||||
import { api, uploadFile } from '$lib/api';
|
||||
import { selectedTask } from '$lib/stores/selectedTask.svelte.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { Button, Select, Input, Icon } from '$lib/ui';
|
||||
import DocPreview from '$lib/components/llm/DocPreview.svelte';
|
||||
import DatasetSearchCombobox from '$lib/components/ui/DatasetSearchCombobox.svelte';
|
||||
// [/SECTION]
|
||||
|
||||
let envs = [];
|
||||
@@ -31,11 +38,18 @@
|
||||
let source = 'sqllab';
|
||||
let databaseId = '';
|
||||
let sqlQuery = '';
|
||||
let excelPath = '';
|
||||
|
||||
// File upload state
|
||||
let uploadedFile: { name: string; uploadId: string } | null = null;
|
||||
let isUploading = false;
|
||||
let isDragOver = false;
|
||||
let isRunning = false;
|
||||
let isGeneratingDocs = false;
|
||||
let generatedDoc = null;
|
||||
|
||||
// Hidden file input reference
|
||||
let fileInputEl: HTMLInputElement | undefined = undefined;
|
||||
|
||||
// #region fetchData:Function [TYPE Function]
|
||||
// @ingroup Components
|
||||
// @PURPOSE: Fetches environments.
|
||||
@@ -57,9 +71,86 @@
|
||||
}
|
||||
// #endregion fetchData:Function
|
||||
|
||||
// #region uploadSingleFile:Function [TYPE Function]
|
||||
// @ingroup Components
|
||||
// @PURPOSE: Upload a single XLSX file to backend and update uploadedFile state.
|
||||
// @PRE: file is a non-null File.
|
||||
// @POST: On success, uploadedFile set with name + temp path. On failure, error toast shown.
|
||||
// @RELATION DEPENDS_ON -> [uploadFile]
|
||||
async function uploadSingleFile(file: File) {
|
||||
// Client-side extension check
|
||||
if (!file.name.toLowerCase().endsWith('.xlsx')) {
|
||||
addToast($t.mapper?.errors?.upload_failed || 'Only .xlsx files are accepted', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading = true;
|
||||
try {
|
||||
const result = await uploadFile<{ upload_id: string }>('/tools/mapper/upload-xlsx', file);
|
||||
uploadedFile = { name: file.name, uploadId: result.upload_id };
|
||||
addToast($t.mapper?.success?.file_uploaded || 'Excel file uploaded', 'success');
|
||||
if (fileInputEl) fileInputEl.value = '';
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||
addToast($t.mapper?.errors?.upload_failed || message, 'error');
|
||||
if (fileInputEl) fileInputEl.value = '';
|
||||
} finally {
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
// #endregion uploadSingleFile:Function
|
||||
|
||||
// #region handleFileSelect:Function [TYPE Function]
|
||||
// @ingroup Components
|
||||
// @PURPOSE: Handle file input change event — upload selected XLSX.
|
||||
// @PRE: event target has a File with .xlsx extension.
|
||||
// @POST: Delegates to uploadSingleFile.
|
||||
// @RELATION CALLS -> [uploadSingleFile]
|
||||
async function handleFileSelect(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadSingleFile(file);
|
||||
}
|
||||
// #endregion handleFileSelect:Function
|
||||
|
||||
// #region handleDragDrop:Function [TYPE Function]
|
||||
// @ingroup Components
|
||||
// @PURPOSE: Handle drag events for drag-and-drop file upload.
|
||||
// @PRE: event is a DragEvent from the file drop zone.
|
||||
// @POST: On drop, uploads the first .xlsx file. Visual feedback via isDragOver.
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
isDragOver = true;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
isDragOver = false;
|
||||
}
|
||||
|
||||
async function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
isDragOver = false;
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadSingleFile(file);
|
||||
}
|
||||
// #endregion handleDragDrop:Function
|
||||
|
||||
// #region handleRemoveFile:Function [TYPE Function]
|
||||
// @ingroup Components
|
||||
// @PURPOSE: Clear uploaded file.
|
||||
// @PRE: uploadedFile is not null.
|
||||
// @POST: uploadedFile set to null, file input reset.
|
||||
function handleRemoveFile() {
|
||||
uploadedFile = null;
|
||||
if (fileInputEl) fileInputEl.value = '';
|
||||
}
|
||||
// #endregion handleRemoveFile:Function
|
||||
|
||||
// #region handleRunMapper:Function [TYPE Function]
|
||||
// @ingroup Components
|
||||
// @PURPOSE: Triggers the MapperPlugin task via new sqllab/excel sources.
|
||||
// @PURPOSE: Triggers the MapperPlugin task via sqllab/excel sources.
|
||||
// @PRE: selectedEnv and datasetId are set; source-specific fields are valid.
|
||||
// @POST: Mapper task is started and selectedTask is updated.
|
||||
async function handleRunMapper() {
|
||||
@@ -73,27 +164,44 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === 'excel' && !excelPath) {
|
||||
addToast($t.mapper.errors.excel_required, 'warning');
|
||||
if (source === 'excel' && !uploadedFile) {
|
||||
addToast($t.mapper.errors.excel_required || 'Please upload an Excel file', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate numeric fields
|
||||
const parsedDatasetId = Number(datasetId);
|
||||
if (!Number.isInteger(parsedDatasetId) || parsedDatasetId <= 0) {
|
||||
addToast($t.mapper.errors.dataset_id_positive, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedDatabaseId = source === 'sqllab' && databaseId ? Number(databaseId) : undefined;
|
||||
if (parsedDatabaseId !== undefined && (!Number.isInteger(parsedDatabaseId) || parsedDatabaseId <= 0)) {
|
||||
addToast($t.mapper.errors.database_id_positive, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
try {
|
||||
const env = envs.find(e => e.id === selectedEnv);
|
||||
if (!env) {
|
||||
addToast($t.mapper.errors.env_not_found || 'Environment not found', 'error');
|
||||
return;
|
||||
}
|
||||
const task = await runTask('dataset-mapper', {
|
||||
env: env.name,
|
||||
dataset_id: parseInt(datasetId),
|
||||
dataset_id: parsedDatasetId,
|
||||
source,
|
||||
database_id: databaseId ? parseInt(databaseId) : undefined,
|
||||
database_id: parsedDatabaseId,
|
||||
sql_query: sqlQuery || undefined,
|
||||
excel_path: excelPath
|
||||
upload_id: uploadedFile?.uploadId ?? undefined,
|
||||
});
|
||||
|
||||
selectedTask.set(task);
|
||||
addToast($t.mapper.success.started, 'success');
|
||||
} catch (e) {
|
||||
addToast(e.message, 'error');
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Task failed', 'error');
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
@@ -130,23 +238,37 @@
|
||||
|
||||
selectedTask.set(task);
|
||||
addToast($t.mapper?.success?.docs_started, 'success');
|
||||
} catch (e) {
|
||||
addToast(e.message || $t.mapper?.errors?.docs_start_failed, 'error');
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : $t.mapper?.errors?.docs_start_failed || 'Docs start failed', 'error');
|
||||
} finally {
|
||||
isGeneratingDocs = false;
|
||||
}
|
||||
}
|
||||
// #endregion handleGenerateDocs:Function
|
||||
|
||||
async function handleApplyDoc(doc) {
|
||||
// #region handleApplyDoc:Function [TYPE Function]
|
||||
// @ingroup Components
|
||||
// @PURPOSE: Apply LLM-generated metadata (description + column docs) to the dataset.
|
||||
// @PRE: datasetId and selectedEnv are set; doc has { description?, columns? }.
|
||||
// @POST: Dataset metadata updated via PUT /api/mappings/datasets/{id}/metadata.
|
||||
// @RELATION DEPENDS_ON -> [api.requestApi]
|
||||
async function handleApplyDoc(doc: { description?: string; columns?: Record<string, string> }) {
|
||||
if (!selectedEnv) {
|
||||
addToast('Environment is required to apply documentation', 'warning');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(`/mappings/datasets/${datasetId}/metadata`, doc);
|
||||
await api.requestApi(`/mappings/datasets/${datasetId}/metadata`, 'PUT', {
|
||||
env_id: selectedEnv,
|
||||
...doc,
|
||||
});
|
||||
generatedDoc = null;
|
||||
addToast($t.mapper?.success?.docs_applied, 'success');
|
||||
} catch (err) {
|
||||
addToast(err.message || $t.mapper?.errors?.docs_apply_failed, 'error');
|
||||
} catch (err: unknown) {
|
||||
addToast(err instanceof Error ? err.message : $t.mapper?.errors?.docs_apply_failed || 'Apply failed', 'error');
|
||||
}
|
||||
}
|
||||
// #endregion handleApplyDoc:Function
|
||||
|
||||
onMount(fetchData);
|
||||
</script>
|
||||
@@ -187,15 +309,12 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-text">
|
||||
<Icon name="clipboard" size={16} className="text-text-subtle" />
|
||||
{$t.mapper.dataset_id}
|
||||
<span class="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
<DatasetSearchCombobox
|
||||
envId={selectedEnv}
|
||||
bind:value={datasetId}
|
||||
placeholder="Введите ID датасета..."
|
||||
label={$t.mapper.dataset_id}
|
||||
id="mapper-dataset-id"
|
||||
placeholder={$t.mapper.dataset_search_placeholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,6 +329,7 @@
|
||||
<label class="flex-1 relative cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="source"
|
||||
bind:group={source}
|
||||
value="sqllab"
|
||||
class="peer sr-only"
|
||||
@@ -230,6 +350,7 @@
|
||||
<label class="flex-1 relative cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="source"
|
||||
bind:group={source}
|
||||
value="excel"
|
||||
class="peer sr-only"
|
||||
@@ -260,22 +381,24 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-text">
|
||||
<label for="mapper-database-id" class="text-sm font-medium text-text">
|
||||
{$t.mapper.database_id}
|
||||
<span class="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="mapper-database-id"
|
||||
bind:value={databaseId}
|
||||
placeholder={$t.mapper.database_id_placeholder}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-text">
|
||||
<label for="mapper-sql-query" class="text-sm font-medium text-text">
|
||||
{$t.mapper.sql_query_label}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="mapper-sql-query"
|
||||
bind:value={sqlQuery}
|
||||
placeholder={$t.mapper.sql_query_placeholder}
|
||||
/>
|
||||
@@ -289,15 +412,76 @@
|
||||
<h3 class="text-sm font-semibold text-text">Настройки Excel</h3>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-text">
|
||||
<label for="mapper-xlsx-file" class="text-sm font-medium text-text">
|
||||
{$t.mapper.excel_path}
|
||||
<span class="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
bind:value={excelPath}
|
||||
placeholder={$t.mapper?.excel_placeholder}
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
type="file"
|
||||
id="mapper-xlsx-file"
|
||||
accept=".xlsx"
|
||||
class="hidden"
|
||||
bind:this={fileInputEl}
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
|
||||
{#if isUploading}
|
||||
<!-- Uploading state -->
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg border border-border bg-surface-muted">
|
||||
<svg class="animate-spin h-5 w-5 text-success" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-sm text-text-muted">{$t.mapper?.uploading || 'Uploading...'}</span>
|
||||
</div>
|
||||
{:else if uploadedFile}
|
||||
<!-- Uploaded state — show file name + remove button -->
|
||||
<div class="flex items-center justify-between gap-3 p-3 rounded-lg border border-success-ring bg-success-light">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-success shrink-0">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-text truncate">{uploadedFile.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRemoveFile}
|
||||
class="shrink-0 p-1 rounded hover:bg-surface-muted transition-colors"
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-text-muted hover:text-destructive">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Idle state — file picker + drag-and-drop zone -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => fileInputEl?.click()}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
class="flex items-center gap-3 w-full p-4 rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer
|
||||
{isDragOver
|
||||
? 'border-success-ring bg-success-light'
|
||||
: 'border-border hover:border-success-ring hover:bg-success-light'
|
||||
}"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={isDragOver ? 'text-success' : 'text-text-muted'}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium {isDragOver ? 'text-success' : 'text-text-muted'}">
|
||||
{isDragOver ? $t.mapper.upload_drop_active : $t.mapper.upload_drop}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -325,7 +509,7 @@
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={handleRunMapper}
|
||||
disabled={isRunning}
|
||||
disabled={isRunning || isUploading || isDragOver}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{#if isRunning}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
envId = '',
|
||||
value = $bindable(''),
|
||||
label = '',
|
||||
id = 'dataset-search-combobox',
|
||||
placeholder = 'Search datasets...',
|
||||
disabled = false,
|
||||
} = $props();
|
||||
@@ -132,13 +133,14 @@
|
||||
|
||||
<div bind:this={containerEl} class="relative">
|
||||
{#if label}
|
||||
<label class="block text-sm font-medium text-text mb-1">{label}</label>
|
||||
<label for={id} class="block text-sm font-medium text-text mb-1">{label}</label>
|
||||
{/if}
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id={id}
|
||||
value={searchQuery}
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
@@ -148,6 +150,7 @@
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={`${id}-listbox`}
|
||||
/>
|
||||
{#if isLoading}
|
||||
<div class="absolute right-2.5 top-2.5 animate-spin h-4 w-4 border-2 border-primary-ring border-t-transparent rounded-full"></div>
|
||||
@@ -169,7 +172,7 @@
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if isOpen}
|
||||
<div class="absolute z-50 mt-1 w-full bg-surface-card border border-border rounded-lg shadow-lg" role="listbox">
|
||||
<div id={`${id}-listbox`} class="absolute z-50 mt-1 w-full bg-surface-card border border-border rounded-lg shadow-lg" role="listbox">
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
{#if isLoading && datasets.length === 0}
|
||||
<div class="px-3 py-6 text-center text-sm text-text-subtle">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"environment": "Environment",
|
||||
"select_env": "-- Select Environment --",
|
||||
"dataset_id": "Dataset ID",
|
||||
"dataset_search_placeholder": "Search datasets...",
|
||||
"source": "Mapping Source",
|
||||
"source_sqllab": "Superset SQL Lab",
|
||||
"source_excel": "Excel",
|
||||
@@ -10,7 +11,11 @@
|
||||
"database_id_placeholder": "Enter Superset database ID...",
|
||||
"sql_query_label": "SQL Query (optional)",
|
||||
"sql_query_placeholder": "SELECT column_name, description AS verbose_name FROM my_mappings WHERE table_name = 'my_table'",
|
||||
"excel_path": "Excel File Path",
|
||||
"excel_path": "Excel File",
|
||||
"upload_drop": "Choose an xlsx file or drag it here",
|
||||
"upload_drop_active": "Drop the file to upload",
|
||||
"dataset_id_positive": "Dataset ID must be a positive integer",
|
||||
"database_id_positive": "Database ID must be a positive integer",
|
||||
"run": "Run Mapper",
|
||||
"starting": "Starting...",
|
||||
"generating": "Generating...",
|
||||
@@ -34,4 +39,4 @@
|
||||
},
|
||||
"auto_document": "Auto-Document",
|
||||
"excel_placeholder": "/path/to/mapping.xlsx"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"environment": "Окружение",
|
||||
"select_env": "-- Выберите окружение --",
|
||||
"dataset_id": "ID датасета",
|
||||
"dataset_search_placeholder": "Поиск датасетов...",
|
||||
"source": "Источник маппинга",
|
||||
"source_sqllab": "Superset SQL Lab",
|
||||
"source_excel": "Excel",
|
||||
@@ -10,7 +11,11 @@
|
||||
"database_id_placeholder": "Введите ID базы данных Superset...",
|
||||
"sql_query_label": "SQL-запрос (опционально)",
|
||||
"sql_query_placeholder": "SELECT column_name, description AS verbose_name FROM my_mappings WHERE table_name = 'my_table'",
|
||||
"excel_path": "Путь к файлу Excel",
|
||||
"excel_path": "Файл Excel",
|
||||
"upload_drop": "Выберите xlsx-файл или перетащите его сюда",
|
||||
"upload_drop_active": "Отпустите файл для загрузки",
|
||||
"dataset_id_positive": "ID датасета должен быть положительным целым числом",
|
||||
"database_id_positive": "ID базы данных должен быть положительным целым числом",
|
||||
"run": "Запустить маппер",
|
||||
"starting": "Запуск...",
|
||||
"generating": "Генерация...",
|
||||
@@ -34,4 +39,4 @@
|
||||
},
|
||||
"auto_document": "Авто-документирование",
|
||||
"excel_placeholder": "/path/to/mapping.xlsx"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user