xladmin-import-export 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ <div align="center">
2
+ <a href="./README.md">
3
+ <img src="https://img.shields.io/badge/English-blue?style=for-the-badge" alt="English">
4
+ </a>
5
+ <a href="./docs/README.ru.md">
6
+ <img src="https://img.shields.io/badge/%D0%A0%D1%83%D1%81%D1%81%D0%BA%D0%B8%D0%B9-red?style=for-the-badge" alt="Русский">
7
+ </a>
8
+ </div>
9
+
10
+ # xladmin-import-export frontend
11
+
12
+ Optional frontend extension for `xladmin` that adds import/export actions to model pages.
13
+
14
+ ## Features
15
+
16
+ - export and import icon buttons for the model toolbar
17
+ - export dialog with format and field selection
18
+ - import dialog with file upload, conflict mode, validation preview, and commit
19
+ - works with `xladmin` selection state, including "select all current results"
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm i xladmin xladmin-import-export
25
+ ```
26
+
27
+ ## Minimal Example
28
+
29
+ ```tsx
30
+ import {ModelPage} from 'xladmin';
31
+ import {
32
+ ModelImportExportActions,
33
+ createAxiosXLAdminImportExportClient,
34
+ } from 'xladmin-import-export';
35
+
36
+ const importExportClient = createAxiosXLAdminImportExportClient(api);
37
+
38
+ <ModelPage
39
+ client={client}
40
+ basePath="/admin"
41
+ slug="users"
42
+ renderBeforePagination={(context) => (
43
+ <ModelImportExportActions client={importExportClient} context={context} />
44
+ )}
45
+ />
46
+ ```
47
+
48
+ ## Development
49
+
50
+ ```bash
51
+ cd xladmin-frontend
52
+ npm run test --workspace ./packages/xladmin-import-export
53
+ npm run check --workspace ./packages/xladmin-import-export
54
+ npm run build --workspace ./packages/xladmin-import-export
55
+ ```
@@ -0,0 +1,110 @@
1
+ import { XLAdminRequestOptions, ModelPageToolbarContext } from 'xladmin';
2
+ import * as react_jsx_runtime from 'react/jsx-runtime';
3
+
4
+ type ImportExportFieldMeta = {
5
+ name: string;
6
+ label: string;
7
+ default_selected: boolean;
8
+ };
9
+ type ImportConflictMode = 'auto_generate_pk' | 'update_existing' | 'skip_existing';
10
+ type ImportExportFormat = 'xlsx' | 'csv' | 'json';
11
+ type ImportExportMetaResponse = {
12
+ model_slug: string;
13
+ export_formats: ImportExportFormat[];
14
+ import_formats: ImportExportFormat[];
15
+ export_fields: ImportExportFieldMeta[];
16
+ import_fields: ImportExportFieldMeta[];
17
+ pk_field: string;
18
+ pk_type: string;
19
+ available_conflict_modes: ImportConflictMode[];
20
+ };
21
+ type ImportValidationResponse = {
22
+ summary: {
23
+ total_rows: number;
24
+ create: number;
25
+ update: number;
26
+ skip: number;
27
+ errors: number;
28
+ };
29
+ created_preview: Array<{
30
+ row_number: number;
31
+ label: string;
32
+ }>;
33
+ updated_preview: Array<{
34
+ row_number: number;
35
+ label: string;
36
+ }>;
37
+ skipped_preview: Array<{
38
+ row_number: number;
39
+ label: string;
40
+ }>;
41
+ errors: Array<{
42
+ row_number: number;
43
+ field?: string | null;
44
+ message: string;
45
+ }>;
46
+ };
47
+ type ImportCommitResponse = {
48
+ created: number;
49
+ updated: number;
50
+ skipped: number;
51
+ };
52
+ type ImportExportSelectionScope = {
53
+ q?: string;
54
+ sort?: string;
55
+ filters?: Record<string, string>;
56
+ };
57
+ type ExportRequestPayload = {
58
+ format: ImportExportFormat;
59
+ fields: string[];
60
+ ids: Array<string | number>;
61
+ select_all?: boolean;
62
+ selection_scope?: ImportExportSelectionScope;
63
+ };
64
+ type ImportRequestPayload = {
65
+ file: File;
66
+ format: ImportExportFormat;
67
+ fields: string[];
68
+ conflict_mode: ImportConflictMode;
69
+ };
70
+ type DownloadExportResponse = {
71
+ blob: Blob;
72
+ filename: string;
73
+ };
74
+ type XLAdminImportExportAxiosLike = {
75
+ get: <T>(url: string, config?: {
76
+ signal?: AbortSignal;
77
+ }) => Promise<{
78
+ data: T;
79
+ } | T>;
80
+ post: <T>(url: string, body?: unknown, config?: {
81
+ signal?: AbortSignal;
82
+ headers?: Record<string, string>;
83
+ responseType?: 'blob';
84
+ }) => Promise<{
85
+ data: T;
86
+ headers?: Record<string, string>;
87
+ } | T>;
88
+ };
89
+ type XLAdminImportExportClient = {
90
+ getMeta: (slug: string, options?: XLAdminRequestOptions) => Promise<ImportExportMetaResponse>;
91
+ downloadExport: (slug: string, payload: ExportRequestPayload, options?: XLAdminRequestOptions) => Promise<DownloadExportResponse>;
92
+ validateImport: (slug: string, payload: ImportRequestPayload, options?: XLAdminRequestOptions) => Promise<ImportValidationResponse>;
93
+ commitImport: (slug: string, payload: ImportRequestPayload, options?: XLAdminRequestOptions) => Promise<ImportCommitResponse>;
94
+ };
95
+ type XLAdminImportExportFetchClientConfig = {
96
+ baseUrl: string;
97
+ fetch?: typeof globalThis.fetch;
98
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
99
+ credentials?: RequestCredentials;
100
+ };
101
+ declare function createAxiosXLAdminImportExportClient(api: XLAdminImportExportAxiosLike): XLAdminImportExportClient;
102
+ declare function createFetchXLAdminImportExportClient(config: XLAdminImportExportFetchClientConfig): XLAdminImportExportClient;
103
+
104
+ type ModelImportExportActionsProps = {
105
+ client: XLAdminImportExportClient;
106
+ context: ModelPageToolbarContext;
107
+ };
108
+ declare function ModelImportExportActions({ client, context }: ModelImportExportActionsProps): react_jsx_runtime.JSX.Element | null;
109
+
110
+ export { type DownloadExportResponse, type ExportRequestPayload, type ImportCommitResponse, type ImportConflictMode, type ImportExportFieldMeta, type ImportExportFormat, type ImportExportMetaResponse, type ImportExportSelectionScope, type ImportRequestPayload, type ImportValidationResponse, ModelImportExportActions, type XLAdminImportExportAxiosLike, type XLAdminImportExportClient, type XLAdminImportExportFetchClientConfig, createAxiosXLAdminImportExportClient, createFetchXLAdminImportExportClient };
package/dist/index.js ADDED
@@ -0,0 +1,729 @@
1
+ // src/client.ts
2
+ function createAxiosXLAdminImportExportClient(api) {
3
+ return {
4
+ async getMeta(slug, options) {
5
+ return unwrap(await api.get(`/xladmin/models/${slug}/import-export/meta/`, {
6
+ signal: options == null ? void 0 : options.signal
7
+ }));
8
+ },
9
+ async downloadExport(slug, payload, options) {
10
+ var _a;
11
+ const response = await api.post(
12
+ `/xladmin/models/${slug}/export/`,
13
+ payload,
14
+ {
15
+ signal: options == null ? void 0 : options.signal,
16
+ responseType: "blob"
17
+ }
18
+ );
19
+ const headers = isWrappedResponse(response) && response.headers ? response.headers : void 0;
20
+ const blob = unwrap(response);
21
+ return {
22
+ blob,
23
+ filename: (_a = resolveFilename(headers)) != null ? _a : `${slug}-export.${payload.format}`
24
+ };
25
+ },
26
+ async validateImport(slug, payload, options) {
27
+ return unwrap(await api.post(
28
+ `/xladmin/models/${slug}/import/validate/`,
29
+ await buildImportFormData(payload),
30
+ {
31
+ signal: options == null ? void 0 : options.signal,
32
+ headers: {}
33
+ }
34
+ ));
35
+ },
36
+ async commitImport(slug, payload, options) {
37
+ return unwrap(await api.post(
38
+ `/xladmin/models/${slug}/import/commit/`,
39
+ await buildImportFormData(payload),
40
+ {
41
+ signal: options == null ? void 0 : options.signal,
42
+ headers: {}
43
+ }
44
+ ));
45
+ }
46
+ };
47
+ }
48
+ function createFetchXLAdminImportExportClient(config) {
49
+ var _a;
50
+ const fetchImpl = (_a = config.fetch) != null ? _a : globalThis.fetch;
51
+ if (!fetchImpl) {
52
+ throw new Error("Fetch API is not available in the current environment.");
53
+ }
54
+ return {
55
+ async getMeta(slug, options) {
56
+ return await requestJson(fetchImpl, config, "GET", `/xladmin/models/${slug}/import-export/meta/`, void 0, options == null ? void 0 : options.signal);
57
+ },
58
+ async downloadExport(slug, payload, options) {
59
+ var _a2;
60
+ const response = await request(fetchImpl, config, "POST", `/xladmin/models/${slug}/export/`, payload, options == null ? void 0 : options.signal);
61
+ return {
62
+ blob: await response.blob(),
63
+ filename: (_a2 = resolveFilename(headersToRecord(response.headers))) != null ? _a2 : `${slug}-export.${payload.format}`
64
+ };
65
+ },
66
+ async validateImport(slug, payload, options) {
67
+ return await requestJson(
68
+ fetchImpl,
69
+ config,
70
+ "POST",
71
+ `/xladmin/models/${slug}/import/validate/`,
72
+ await buildImportFormData(payload),
73
+ options == null ? void 0 : options.signal
74
+ );
75
+ },
76
+ async commitImport(slug, payload, options) {
77
+ return await requestJson(
78
+ fetchImpl,
79
+ config,
80
+ "POST",
81
+ `/xladmin/models/${slug}/import/commit/`,
82
+ await buildImportFormData(payload),
83
+ options == null ? void 0 : options.signal
84
+ );
85
+ }
86
+ };
87
+ }
88
+ async function buildImportFormData(payload) {
89
+ const formData = new FormData();
90
+ formData.append("file", await createStableUploadFile(payload.file), payload.file.name);
91
+ formData.append("format", payload.format);
92
+ formData.append("conflict_mode", payload.conflict_mode);
93
+ formData.append("fields", JSON.stringify(payload.fields));
94
+ return formData;
95
+ }
96
+ async function createStableUploadFile(file) {
97
+ const buffer = await file.arrayBuffer();
98
+ return new File([buffer], file.name, {
99
+ type: file.type,
100
+ lastModified: file.lastModified
101
+ });
102
+ }
103
+ function isWrappedResponse(response) {
104
+ return typeof response === "object" && response !== null && "data" in response;
105
+ }
106
+ function unwrap(response) {
107
+ return isWrappedResponse(response) ? response.data : response;
108
+ }
109
+ function resolveFilename(headers) {
110
+ var _a, _b;
111
+ const contentDisposition = (_a = headers == null ? void 0 : headers["content-disposition"]) != null ? _a : headers == null ? void 0 : headers["Content-Disposition"];
112
+ if (!contentDisposition) {
113
+ return null;
114
+ }
115
+ const match = /filename="([^"]+)"/.exec(contentDisposition);
116
+ return (_b = match == null ? void 0 : match[1]) != null ? _b : null;
117
+ }
118
+ function headersToRecord(headers) {
119
+ const normalizedHeaders = {};
120
+ headers.forEach((value, key) => {
121
+ normalizedHeaders[key] = value;
122
+ });
123
+ return normalizedHeaders;
124
+ }
125
+ async function requestJson(fetchImpl, config, method, path, body, signal) {
126
+ const response = await request(fetchImpl, config, method, path, body, signal);
127
+ return await response.json();
128
+ }
129
+ async function request(fetchImpl, config, method, path, body, signal) {
130
+ var _a;
131
+ const headers = await resolveHeaders(config.headers);
132
+ const isFormData = body instanceof FormData;
133
+ const response = await fetchImpl(buildRequestUrl(config.baseUrl, path), {
134
+ method,
135
+ signal,
136
+ credentials: (_a = config.credentials) != null ? _a : "include",
137
+ headers: isFormData ? headers : {
138
+ "Content-Type": "application/json",
139
+ ...headers
140
+ },
141
+ body: body === void 0 ? void 0 : isFormData ? body : JSON.stringify(body)
142
+ });
143
+ if (!response.ok) {
144
+ let detail = `${response.status} ${response.statusText}`;
145
+ try {
146
+ const errorData = await response.json();
147
+ if (typeof errorData.detail === "string" && errorData.detail) {
148
+ detail = errorData.detail;
149
+ }
150
+ } catch {
151
+ const fallbackText = await response.text().catch(() => "");
152
+ if (fallbackText.trim()) {
153
+ detail = fallbackText.trim();
154
+ }
155
+ }
156
+ throw new Error(detail);
157
+ }
158
+ return response;
159
+ }
160
+ async function resolveHeaders(headers) {
161
+ if (headers === void 0) {
162
+ return {};
163
+ }
164
+ if (typeof headers === "function") {
165
+ return headers();
166
+ }
167
+ return headers;
168
+ }
169
+ function buildRequestUrl(baseUrl, path) {
170
+ const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
171
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
172
+ if (/^https?:\/\//i.test(normalizedBaseUrl)) {
173
+ return `${normalizedBaseUrl}${normalizedPath}`;
174
+ }
175
+ return `${normalizedBaseUrl}${normalizedPath}`;
176
+ }
177
+
178
+ // src/components/ModelImportExportActions.tsx
179
+ import { useEffect, useMemo, useState } from "react";
180
+ import DownloadIcon from "@mui/icons-material/Download";
181
+ import UploadIcon from "@mui/icons-material/Upload";
182
+ import {
183
+ Alert,
184
+ Box,
185
+ Button,
186
+ Checkbox,
187
+ CircularProgress,
188
+ Dialog,
189
+ DialogActions,
190
+ DialogContent,
191
+ DialogTitle,
192
+ FormControl,
193
+ FormControlLabel,
194
+ IconButton,
195
+ InputLabel,
196
+ List,
197
+ ListItem,
198
+ MenuItem,
199
+ Select,
200
+ Stack,
201
+ Tooltip,
202
+ Typography
203
+ } from "@mui/material";
204
+ import { useAdminLocale } from "xladmin";
205
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
206
+ function ModelImportExportActions({ client, context }) {
207
+ const locale = useAdminLocale();
208
+ const messages = useMemo(() => getMessages(locale), [locale]);
209
+ const [meta, setMeta] = useState(null);
210
+ const [isUnavailable, setIsUnavailable] = useState(false);
211
+ const [error, setError] = useState(null);
212
+ const [exportOpen, setExportOpen] = useState(false);
213
+ const [importOpen, setImportOpen] = useState(false);
214
+ const [exportFormat, setExportFormat] = useState("xlsx");
215
+ const [selectedExportFields, setSelectedExportFields] = useState([]);
216
+ const [isExporting, setIsExporting] = useState(false);
217
+ const [importFormat, setImportFormat] = useState("xlsx");
218
+ const [selectedImportFields, setSelectedImportFields] = useState([]);
219
+ const [conflictMode, setConflictMode] = useState("update_existing");
220
+ const [importFile, setImportFile] = useState(null);
221
+ const [isValidating, setIsValidating] = useState(false);
222
+ const [isImporting, setIsImporting] = useState(false);
223
+ const [validationResult, setValidationResult] = useState(null);
224
+ const [validationError, setValidationError] = useState(null);
225
+ const [importSuccess, setImportSuccess] = useState(null);
226
+ const resetImportValidation = () => {
227
+ setValidationResult(null);
228
+ setValidationError(null);
229
+ setImportSuccess(null);
230
+ };
231
+ useEffect(() => {
232
+ let isMounted = true;
233
+ setMeta(null);
234
+ setIsUnavailable(false);
235
+ setError(null);
236
+ client.getMeta(context.slug).then((response) => {
237
+ var _a, _b, _c;
238
+ if (!isMounted) {
239
+ return;
240
+ }
241
+ setMeta(response);
242
+ setExportFormat((_a = response.export_formats[0]) != null ? _a : "xlsx");
243
+ setSelectedExportFields(getDefaultSelectedFields(response.export_fields));
244
+ setImportFormat((_b = response.import_formats[0]) != null ? _b : "xlsx");
245
+ setSelectedImportFields(getDefaultSelectedFields(response.import_fields));
246
+ setConflictMode((_c = response.available_conflict_modes[0]) != null ? _c : "update_existing");
247
+ }).catch((reason) => {
248
+ if (!isMounted) {
249
+ return;
250
+ }
251
+ const message = reason instanceof Error ? reason.message : "Failed to load import/export settings.";
252
+ if (message.includes("404")) {
253
+ setIsUnavailable(true);
254
+ return;
255
+ }
256
+ setError(message);
257
+ });
258
+ return () => {
259
+ isMounted = false;
260
+ };
261
+ }, [client, context.slug]);
262
+ if (isUnavailable || meta === null) {
263
+ return null;
264
+ }
265
+ const exportCount = context.selectionCount > 0 ? context.selectionCount : context.total;
266
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
267
+ /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", children: [
268
+ /* @__PURE__ */ jsx(Tooltip, { title: messages.exportButton, children: /* @__PURE__ */ jsx("span", { children: /* @__PURE__ */ jsx(IconButton, { size: "small", onClick: () => setExportOpen(true), disabled: Boolean(error), children: /* @__PURE__ */ jsx(DownloadIcon, { fontSize: "small" }) }) }) }),
269
+ /* @__PURE__ */ jsx(Tooltip, { title: messages.importButton, children: /* @__PURE__ */ jsx("span", { children: /* @__PURE__ */ jsx(IconButton, { size: "small", onClick: () => setImportOpen(true), disabled: Boolean(error), children: /* @__PURE__ */ jsx(UploadIcon, { fontSize: "small" }) }) }) })
270
+ ] }),
271
+ /* @__PURE__ */ jsxs(Dialog, { open: exportOpen, onClose: () => !isExporting && setExportOpen(false), fullWidth: true, maxWidth: "md", children: [
272
+ /* @__PURE__ */ jsx(DialogTitle, { sx: { px: 3, pt: 3, pb: 1 }, children: messages.exportTitle }),
273
+ /* @__PURE__ */ jsx(DialogContent, { sx: { px: 3, pb: 3, pt: 0.5 }, children: /* @__PURE__ */ jsxs(Stack, { spacing: 1.5, children: [
274
+ error ? /* @__PURE__ */ jsx(Alert, { severity: "error", children: error }) : null,
275
+ /* @__PURE__ */ jsx(Typography, { color: "text.secondary", children: messages.exportCount(exportCount, context.selectionCount > 0) }),
276
+ /* @__PURE__ */ jsxs(FormControl, { fullWidth: true, children: [
277
+ /* @__PURE__ */ jsx(InputLabel, { id: "export-format-label", children: messages.formatLabel }),
278
+ /* @__PURE__ */ jsx(
279
+ Select,
280
+ {
281
+ labelId: "export-format-label",
282
+ value: exportFormat,
283
+ label: messages.formatLabel,
284
+ onChange: (event) => setExportFormat(event.target.value),
285
+ children: meta.export_formats.map((format) => /* @__PURE__ */ jsx(MenuItem, { value: format, children: format.toUpperCase() }, format))
286
+ }
287
+ )
288
+ ] }),
289
+ /* @__PURE__ */ jsx(
290
+ FieldsSelector,
291
+ {
292
+ title: messages.exportFieldsLabel,
293
+ fields: meta.export_fields,
294
+ selectedFields: selectedExportFields,
295
+ onChange: setSelectedExportFields,
296
+ allLabel: messages.selectAllFields,
297
+ noneLabel: messages.clearFields
298
+ }
299
+ )
300
+ ] }) }),
301
+ /* @__PURE__ */ jsxs(DialogActions, { sx: { px: 3, pb: 3, pt: 0, gap: 1 }, children: [
302
+ /* @__PURE__ */ jsx(Button, { onClick: () => setExportOpen(false), disabled: isExporting, children: messages.cancel }),
303
+ /* @__PURE__ */ jsx(
304
+ Button,
305
+ {
306
+ variant: "contained",
307
+ onClick: () => void handleExport({
308
+ client,
309
+ context,
310
+ format: exportFormat,
311
+ fields: selectedExportFields,
312
+ setIsExporting,
313
+ onDone: () => setExportOpen(false)
314
+ }),
315
+ disabled: isExporting || selectedExportFields.length === 0,
316
+ children: isExporting ? messages.exporting : messages.exportConfirm
317
+ }
318
+ )
319
+ ] })
320
+ ] }),
321
+ /* @__PURE__ */ jsxs(
322
+ Dialog,
323
+ {
324
+ open: importOpen,
325
+ onClose: () => !isValidating && !isImporting && setImportOpen(false),
326
+ fullWidth: true,
327
+ maxWidth: "md",
328
+ children: [
329
+ /* @__PURE__ */ jsx(DialogTitle, { sx: { px: 3, pt: 3, pb: 1 }, children: messages.importTitle }),
330
+ /* @__PURE__ */ jsx(DialogContent, { sx: { px: 3, pb: 3, pt: 0.5 }, children: /* @__PURE__ */ jsxs(Stack, { spacing: 1.5, children: [
331
+ validationError ? /* @__PURE__ */ jsx(Alert, { severity: "error", children: validationError }) : null,
332
+ importSuccess ? /* @__PURE__ */ jsx(Alert, { severity: "success", children: importSuccess }) : null,
333
+ /* @__PURE__ */ jsxs(Stack, { direction: { xs: "column", sm: "row" }, sx: { pt: 1 }, spacing: 1.5, children: [
334
+ /* @__PURE__ */ jsxs(
335
+ Button,
336
+ {
337
+ variant: "outlined",
338
+ component: "label",
339
+ sx: { whiteSpace: "nowrap", flexShrink: 0, minWidth: 132 },
340
+ children: [
341
+ importFile ? importFile.name : messages.chooseFile,
342
+ /* @__PURE__ */ jsx(
343
+ "input",
344
+ {
345
+ hidden: true,
346
+ type: "file",
347
+ accept: ".xlsx,.csv,.json",
348
+ onChange: (event) => {
349
+ var _a, _b;
350
+ const file = (_b = (_a = event.target.files) == null ? void 0 : _a[0]) != null ? _b : null;
351
+ setImportFile(file);
352
+ resetImportValidation();
353
+ if (!file) {
354
+ return;
355
+ }
356
+ const nextFormat = inferFormatFromFile(file.name);
357
+ if (nextFormat !== null && meta.import_formats.includes(nextFormat)) {
358
+ setImportFormat(nextFormat);
359
+ }
360
+ }
361
+ }
362
+ )
363
+ ]
364
+ }
365
+ ),
366
+ /* @__PURE__ */ jsxs(FormControl, { fullWidth: true, children: [
367
+ /* @__PURE__ */ jsx(InputLabel, { id: "import-format-label", children: messages.formatLabel }),
368
+ /* @__PURE__ */ jsx(
369
+ Select,
370
+ {
371
+ labelId: "import-format-label",
372
+ value: importFormat,
373
+ label: messages.formatLabel,
374
+ onChange: (event) => {
375
+ resetImportValidation();
376
+ setImportFormat(event.target.value);
377
+ },
378
+ children: meta.import_formats.map((format) => /* @__PURE__ */ jsx(MenuItem, { value: format, children: format.toUpperCase() }, format))
379
+ }
380
+ )
381
+ ] })
382
+ ] }),
383
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(FormControl, { fullWidth: true, sx: { mt: 0.5 }, children: [
384
+ /* @__PURE__ */ jsx(InputLabel, { id: "import-conflict-mode-label", children: messages.conflictModeLabel }),
385
+ /* @__PURE__ */ jsx(
386
+ Select,
387
+ {
388
+ labelId: "import-conflict-mode-label",
389
+ value: conflictMode,
390
+ label: messages.conflictModeLabel,
391
+ onChange: (event) => {
392
+ resetImportValidation();
393
+ setConflictMode(event.target.value);
394
+ },
395
+ children: meta.available_conflict_modes.map((mode) => /* @__PURE__ */ jsx(MenuItem, { value: mode, children: messages.conflictMode(mode) }, mode))
396
+ }
397
+ )
398
+ ] }) }),
399
+ /* @__PURE__ */ jsx(
400
+ FieldsSelector,
401
+ {
402
+ title: messages.importFieldsLabel,
403
+ fields: meta.import_fields,
404
+ selectedFields: selectedImportFields,
405
+ onChange: (value) => {
406
+ resetImportValidation();
407
+ setSelectedImportFields(value);
408
+ },
409
+ allLabel: messages.selectAllFields,
410
+ noneLabel: messages.clearFields
411
+ }
412
+ ),
413
+ /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [
414
+ /* @__PURE__ */ jsx(
415
+ Button,
416
+ {
417
+ variant: "outlined",
418
+ onClick: () => void handleValidateImport({
419
+ client,
420
+ slug: context.slug,
421
+ file: importFile,
422
+ format: importFormat,
423
+ fields: selectedImportFields,
424
+ conflictMode,
425
+ setIsValidating,
426
+ setValidationResult,
427
+ setValidationError
428
+ }),
429
+ disabled: !importFile || selectedImportFields.length === 0 || isValidating || isImporting,
430
+ children: messages.validate
431
+ }
432
+ ),
433
+ isValidating ? /* @__PURE__ */ jsx(CircularProgress, { size: 20 }) : null
434
+ ] }),
435
+ validationResult ? /* @__PURE__ */ jsx(ValidationSummary, { messages, result: validationResult }) : null
436
+ ] }) }),
437
+ /* @__PURE__ */ jsxs(DialogActions, { sx: { px: 3, pb: 3, pt: 0, gap: 1 }, children: [
438
+ /* @__PURE__ */ jsx(
439
+ Button,
440
+ {
441
+ onClick: () => setImportOpen(false),
442
+ disabled: isValidating || isImporting,
443
+ children: messages.cancel
444
+ }
445
+ ),
446
+ /* @__PURE__ */ jsx(
447
+ Button,
448
+ {
449
+ variant: "contained",
450
+ onClick: () => void handleCommitImport({
451
+ client,
452
+ slug: context.slug,
453
+ file: importFile,
454
+ format: importFormat,
455
+ fields: selectedImportFields,
456
+ conflictMode,
457
+ setIsImporting,
458
+ setValidationError,
459
+ setImportSuccess,
460
+ refresh: context.refresh,
461
+ onSuccess: () => {
462
+ setValidationResult(null);
463
+ setImportFile(null);
464
+ },
465
+ messages
466
+ }),
467
+ disabled: !importFile || !validationResult || validationResult.summary.errors > 0 || isImporting,
468
+ children: isImporting ? messages.importing : messages.confirmImport
469
+ }
470
+ )
471
+ ] })
472
+ ]
473
+ }
474
+ )
475
+ ] });
476
+ }
477
+ function FieldsSelector({
478
+ title,
479
+ fields,
480
+ selectedFields,
481
+ onChange,
482
+ allLabel,
483
+ noneLabel
484
+ }) {
485
+ return /* @__PURE__ */ jsxs(Stack, { children: [
486
+ /* @__PURE__ */ jsxs(Stack, { direction: "row", justifyContent: "space-between", alignItems: "center", children: [
487
+ /* @__PURE__ */ jsx(Typography, { variant: "subtitle2", children: title }),
488
+ /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1, children: [
489
+ /* @__PURE__ */ jsx(Button, { size: "small", onClick: () => onChange(fields.map((field) => field.name)), children: allLabel }),
490
+ /* @__PURE__ */ jsx(Button, { size: "small", onClick: () => onChange([]), children: noneLabel })
491
+ ] })
492
+ ] }),
493
+ /* @__PURE__ */ jsx(
494
+ List,
495
+ {
496
+ dense: true,
497
+ disablePadding: true,
498
+ sx: {
499
+ maxHeight: 280,
500
+ overflow: "auto"
501
+ },
502
+ children: fields.map((field) => /* @__PURE__ */ jsx(ListItem, { disableGutters: true, sx: { py: 0.125 }, children: /* @__PURE__ */ jsx(
503
+ FormControlLabel,
504
+ {
505
+ sx: { m: 0, alignItems: "center" },
506
+ control: /* @__PURE__ */ jsx(
507
+ Checkbox,
508
+ {
509
+ size: "small",
510
+ checked: selectedFields.includes(field.name),
511
+ onChange: (_, checked) => {
512
+ if (checked) {
513
+ onChange([...selectedFields, field.name]);
514
+ return;
515
+ }
516
+ onChange(selectedFields.filter((item) => item !== field.name));
517
+ }
518
+ }
519
+ ),
520
+ label: /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 0.75, alignItems: "center", sx: { minHeight: 24 }, children: [
521
+ /* @__PURE__ */ jsx(Typography, { variant: "body2", sx: { lineHeight: 1.35 }, children: field.label }),
522
+ field.label !== field.name ? /* @__PURE__ */ jsx(Typography, { variant: "caption", color: "text.secondary", sx: { lineHeight: 1.2 }, children: field.name }) : null
523
+ ] })
524
+ }
525
+ ) }, field.name))
526
+ }
527
+ )
528
+ ] });
529
+ }
530
+ function ValidationSummary({
531
+ messages,
532
+ result
533
+ }) {
534
+ return /* @__PURE__ */ jsxs(Stack, { spacing: 1.5, children: [
535
+ /* @__PURE__ */ jsx(Typography, { variant: "subtitle2", children: messages.validationSummaryTitle }),
536
+ /* @__PURE__ */ jsx(Typography, { color: "text.secondary", children: messages.validationSummary(result) }),
537
+ result.errors.length > 0 ? /* @__PURE__ */ jsx(Alert, { severity: "warning", children: /* @__PURE__ */ jsx(Stack, { spacing: 0.5, children: result.errors.slice(0, 10).map((error) => /* @__PURE__ */ jsxs(Typography, { variant: "body2", children: [
538
+ "#",
539
+ error.row_number,
540
+ ": ",
541
+ error.message
542
+ ] }, `${error.row_number}-${error.message}`)) }) }) : null
543
+ ] });
544
+ }
545
+ function getDefaultSelectedFields(fields) {
546
+ const defaultSelected = fields.filter((field) => field.default_selected).map((field) => field.name);
547
+ if (defaultSelected.length > 0) {
548
+ return defaultSelected;
549
+ }
550
+ return fields.map((field) => field.name);
551
+ }
552
+ async function handleExport({
553
+ client,
554
+ context,
555
+ format,
556
+ fields,
557
+ setIsExporting,
558
+ onDone
559
+ }) {
560
+ setIsExporting(true);
561
+ try {
562
+ const response = await client.downloadExport(context.slug, {
563
+ format,
564
+ fields,
565
+ ids: context.isAllMatchingSelected ? [] : context.selectedIds,
566
+ select_all: context.isAllMatchingSelected || context.selectedIds.length === 0,
567
+ selection_scope: {
568
+ q: context.appliedQuery || void 0,
569
+ sort: context.sortValue || void 0,
570
+ filters: context.appliedFilters
571
+ }
572
+ });
573
+ const objectUrl = URL.createObjectURL(response.blob);
574
+ const link = document.createElement("a");
575
+ link.href = objectUrl;
576
+ link.download = response.filename;
577
+ document.body.appendChild(link);
578
+ link.click();
579
+ link.remove();
580
+ URL.revokeObjectURL(objectUrl);
581
+ onDone();
582
+ } finally {
583
+ setIsExporting(false);
584
+ }
585
+ }
586
+ async function handleValidateImport({
587
+ client,
588
+ slug,
589
+ file,
590
+ format,
591
+ fields,
592
+ conflictMode,
593
+ setIsValidating,
594
+ setValidationResult,
595
+ setValidationError
596
+ }) {
597
+ if (!file) {
598
+ return;
599
+ }
600
+ setIsValidating(true);
601
+ setValidationError(null);
602
+ try {
603
+ const result = await client.validateImport(slug, {
604
+ file,
605
+ format,
606
+ fields,
607
+ conflict_mode: conflictMode
608
+ });
609
+ setValidationResult(result);
610
+ } catch (reason) {
611
+ setValidationResult(null);
612
+ setValidationError(reason instanceof Error ? reason.message : "Validation failed.");
613
+ } finally {
614
+ setIsValidating(false);
615
+ }
616
+ }
617
+ async function handleCommitImport({
618
+ client,
619
+ slug,
620
+ file,
621
+ format,
622
+ fields,
623
+ conflictMode,
624
+ setIsImporting,
625
+ setValidationError,
626
+ setImportSuccess,
627
+ refresh,
628
+ onSuccess,
629
+ messages
630
+ }) {
631
+ if (!file) {
632
+ return;
633
+ }
634
+ setIsImporting(true);
635
+ setValidationError(null);
636
+ try {
637
+ const result = await client.commitImport(slug, {
638
+ file,
639
+ format,
640
+ fields,
641
+ conflict_mode: conflictMode
642
+ });
643
+ await refresh();
644
+ onSuccess();
645
+ setImportSuccess(messages.importSuccess(result));
646
+ } catch (reason) {
647
+ setValidationError(reason instanceof Error ? reason.message : "Import failed.");
648
+ } finally {
649
+ setIsImporting(false);
650
+ }
651
+ }
652
+ function inferFormatFromFile(fileName) {
653
+ const normalized = fileName.toLowerCase();
654
+ if (normalized.endsWith(".xlsx")) {
655
+ return "xlsx";
656
+ }
657
+ if (normalized.endsWith(".csv")) {
658
+ return "csv";
659
+ }
660
+ if (normalized.endsWith(".json")) {
661
+ return "json";
662
+ }
663
+ return null;
664
+ }
665
+ function getMessages(locale) {
666
+ if (locale === "ru") {
667
+ return {
668
+ exportButton: "\u042D\u043A\u0441\u043F\u043E\u0440\u0442",
669
+ importButton: "\u0418\u043C\u043F\u043E\u0440\u0442",
670
+ exportTitle: "\u042D\u043A\u0441\u043F\u043E\u0440\u0442 \u0434\u0430\u043D\u043D\u044B\u0445",
671
+ importTitle: "\u0418\u043C\u043F\u043E\u0440\u0442 \u0434\u0430\u043D\u043D\u044B\u0445",
672
+ exporting: "\u042D\u043A\u0441\u043F\u043E\u0440\u0442...",
673
+ importing: "\u0418\u043C\u043F\u043E\u0440\u0442...",
674
+ exportConfirm: "\u042D\u043A\u0441\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C",
675
+ confirmImport: "\u041F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044C \u0438\u043C\u043F\u043E\u0440\u0442",
676
+ cancel: "\u041E\u0442\u043C\u0435\u043D\u0430",
677
+ formatLabel: "\u0424\u043E\u0440\u043C\u0430\u0442",
678
+ exportFieldsLabel: "\u041F\u043E\u043B\u044F \u0434\u043B\u044F \u044D\u043A\u0441\u043F\u043E\u0440\u0442\u0430",
679
+ importFieldsLabel: "\u041F\u043E\u043B\u044F \u0434\u043B\u044F \u0438\u043C\u043F\u043E\u0440\u0442\u0430",
680
+ selectAllFields: "\u0412\u0441\u0435",
681
+ clearFields: "\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C",
682
+ chooseFile: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u0444\u0430\u0439\u043B",
683
+ validate: "\u041F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C",
684
+ conflictModeLabel: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u0435 \u043F\u0440\u0438 \u0441\u043E\u0432\u043F\u0430\u0434\u0435\u043D\u0438\u0438 PK",
685
+ validationSummaryTitle: "\u0420\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438",
686
+ exportCount: (count, selected) => selected ? `\u0411\u0443\u0434\u0435\u0442 \u044D\u043A\u0441\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E \u043E\u0431\u044A\u0435\u043A\u0442\u043E\u0432: ${count}` : `\u0411\u0443\u0434\u0443\u0442 \u044D\u043A\u0441\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u044B \u0432\u0441\u0435 \u043E\u0431\u044A\u0435\u043A\u0442\u044B \u0442\u0435\u043A\u0443\u0449\u0435\u0433\u043E \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442\u0430: ${count}`,
687
+ conflictMode: (mode) => ({
688
+ auto_generate_pk: "\u0421\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043D\u043E\u0432\u044B\u0439 PK",
689
+ update_existing: "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044E\u0449\u0438\u0435",
690
+ skip_existing: "\u041F\u0440\u043E\u043F\u0443\u0441\u0442\u0438\u0442\u044C \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044E\u0449\u0438\u0435"
691
+ })[mode],
692
+ validationSummary: (result) => `\u0412\u0441\u0435\u0433\u043E \u0441\u0442\u0440\u043E\u043A: ${result.summary.total_rows}. \u0421\u043E\u0437\u0434\u0430\u0442\u044C: ${result.summary.create}. \u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C: ${result.summary.update}. \u041F\u0440\u043E\u043F\u0443\u0441\u0442\u0438\u0442\u044C: ${result.summary.skip}. \u041E\u0448\u0438\u0431\u043E\u043A: ${result.summary.errors}.`,
693
+ importSuccess: (result) => `\u0418\u043C\u043F\u043E\u0440\u0442 \u0437\u0430\u0432\u0435\u0440\u0448\u0451\u043D. \u0421\u043E\u0437\u0434\u0430\u043D\u043E: ${result.created}, \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u043E: ${result.updated}, \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D\u043E: ${result.skipped}.`
694
+ };
695
+ }
696
+ return {
697
+ exportButton: "Export",
698
+ importButton: "Import",
699
+ exportTitle: "Export data",
700
+ importTitle: "Import data",
701
+ exporting: "Exporting...",
702
+ importing: "Importing...",
703
+ exportConfirm: "Export",
704
+ confirmImport: "Confirm import",
705
+ cancel: "Cancel",
706
+ formatLabel: "Format",
707
+ exportFieldsLabel: "Fields to export",
708
+ importFieldsLabel: "Fields to import",
709
+ selectAllFields: "All",
710
+ clearFields: "Clear",
711
+ chooseFile: "Choose file",
712
+ validate: "Validate",
713
+ conflictModeLabel: "Conflict mode",
714
+ validationSummaryTitle: "Validation summary",
715
+ exportCount: (count, selected) => selected ? `Objects to export: ${count}` : `All objects from the current result will be exported: ${count}`,
716
+ conflictMode: (mode) => ({
717
+ auto_generate_pk: "Auto-generate PK",
718
+ update_existing: "Update existing",
719
+ skip_existing: "Skip existing"
720
+ })[mode],
721
+ validationSummary: (result) => `Rows: ${result.summary.total_rows}. Create: ${result.summary.create}. Update: ${result.summary.update}. Skip: ${result.summary.skip}. Errors: ${result.summary.errors}.`,
722
+ importSuccess: (result) => `Import completed. Created: ${result.created}, updated: ${result.updated}, skipped: ${result.skipped}.`
723
+ };
724
+ }
725
+ export {
726
+ ModelImportExportActions,
727
+ createAxiosXLAdminImportExportClient,
728
+ createFetchXLAdminImportExportClient
729
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "xladmin-import-export",
3
+ "version": "0.1.0",
4
+ "description": "Optional import/export extension for xladmin.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Artasov/xladmin.git",
13
+ "directory": "xladmin-frontend/packages/xladmin-import-export"
14
+ },
15
+ "homepage": "https://github.com/Artasov/xladmin/tree/main/xladmin-frontend/packages/xladmin-import-export",
16
+ "bugs": {
17
+ "url": "https://github.com/Artasov/xladmin/issues"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js",
23
+ "default": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "node ../../scripts/clean.mjs && tsup src/index.ts --format esm --dts",
35
+ "check": "tsc --noEmit -p tsconfig.json",
36
+ "test": "vitest run --environment jsdom --pool threads"
37
+ },
38
+ "peerDependencies": {
39
+ "@emotion/react": ">=11.14.0 <12.0.0",
40
+ "@emotion/styled": ">=11.14.0 <12.0.0",
41
+ "@mui/icons-material": ">=7.0.0 <8.0.0",
42
+ "@mui/material": ">=7.0.0 <8.0.0",
43
+ "axios": ">=1.0.0 <2.0.0",
44
+ "react": ">=19.0.0 <20.0.0",
45
+ "react-dom": ">=19.0.0 <20.0.0",
46
+ "xladmin": ">=0.2.8 <1.0.0"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "axios": {
50
+ "optional": true
51
+ }
52
+ }
53
+ }