feat(mapper): secure xlsx upload and dataset selection

This commit is contained in:
2026-07-14 00:34:10 +03:00
parent d630573402
commit 66497da72b
14 changed files with 1040 additions and 56 deletions

View File

@@ -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;

View File

@@ -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');

View File

@@ -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}

View File

@@ -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">

View File

@@ -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"
}
}

View File

@@ -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"
}
}