plugin-migration-manager 2.0.0 → 2.0.2

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.
@@ -0,0 +1,808 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import { Alert, Button, Card, Input, Modal, Space, Spin, Table, Tabs, Tag, Typography, Upload, message } from 'antd';
3
+ import {
4
+ BranchesOutlined,
5
+ DatabaseOutlined,
6
+ DownloadOutlined,
7
+ ExclamationCircleOutlined,
8
+ LayoutOutlined,
9
+ UploadOutlined,
10
+ } from '@ant-design/icons';
11
+ import { useFlowContext } from '@nocobase/flow-engine';
12
+ import { useRequest } from 'ahooks';
13
+ import { useT } from '../locale';
14
+
15
+ const { Title, Text } = Typography;
16
+ const { Search } = Input;
17
+
18
+ type Id = string | number;
19
+ type Dict = Record<string, unknown>;
20
+
21
+ type CollectionItem = {
22
+ name: string;
23
+ title?: string;
24
+ fields?: number;
25
+ };
26
+
27
+ type WorkflowItem = {
28
+ id: Id;
29
+ title?: string;
30
+ key?: string;
31
+ enabled?: boolean;
32
+ };
33
+
34
+ type UISchemaItem = {
35
+ routeId?: Id;
36
+ displayTitle?: string;
37
+ title?: string;
38
+ name?: string;
39
+ type?: string;
40
+ schemaUid?: string;
41
+ uid?: string;
42
+ };
43
+
44
+ type ItemsShape = {
45
+ collections: CollectionItem[];
46
+ workflows: WorkflowItem[];
47
+ uiSchemas: UISchemaItem[];
48
+ };
49
+
50
+ type MigrationPayload = {
51
+ collections?: unknown[];
52
+ workflows?: unknown[];
53
+ uiSchemas?: unknown[];
54
+ desktopRoutes?: unknown[];
55
+ options?: Dict;
56
+ };
57
+
58
+ type ErrorItem = {
59
+ collection?: string;
60
+ route?: string;
61
+ schema?: string;
62
+ error?: string;
63
+ };
64
+
65
+ type ImportResults = {
66
+ collections?: { success?: number; failed?: number; errors?: ErrorItem[]; conflicts?: ConflictRow[] };
67
+ workflows?: { success?: number; failed?: number; errors?: ErrorItem[] };
68
+ uiSchemas?: { success?: number; failed?: number; errors?: ErrorItem[] };
69
+ };
70
+
71
+ type ConflictRow = {
72
+ collection: string;
73
+ field: string;
74
+ current: Dict;
75
+ incoming: Dict;
76
+ };
77
+
78
+ type WrappedResponse<T> = {
79
+ data?: T | { data?: T };
80
+ };
81
+
82
+ function isRecord(value: unknown): value is Dict {
83
+ return typeof value === 'object' && value !== null;
84
+ }
85
+
86
+ function unwrapResponse<T>(response: unknown): T {
87
+ if (isRecord(response) && isRecord(response.data)) {
88
+ if ('data' in response.data) {
89
+ return response.data.data as T;
90
+ }
91
+ return response.data as T;
92
+ }
93
+ return response as T;
94
+ }
95
+
96
+ function getErrorMessage(error: unknown, fallback: string) {
97
+ return error instanceof Error ? error.message : fallback;
98
+ }
99
+
100
+ function prettifyCollectionTitle(title?: string) {
101
+ if (!title) {
102
+ return '';
103
+ }
104
+ return title
105
+ .replace(/\{\{t\("(.+?)"\)\}\}/g, '$1')
106
+ .replace(/_/g, ' ')
107
+ .replace(/\b\w/g, (c) => c.toUpperCase());
108
+ }
109
+
110
+ function delay(ms: number) {
111
+ return new Promise((resolve) => setTimeout(resolve, ms));
112
+ }
113
+
114
+ function getDisplayText(value: unknown) {
115
+ return value === undefined || value === null ? '' : String(value);
116
+ }
117
+
118
+ function getCounts(payload: MigrationPayload) {
119
+ return {
120
+ collections: payload.collections?.length || 0,
121
+ workflows: payload.workflows?.length || 0,
122
+ uiSchemas: payload.uiSchemas?.length || 0,
123
+ desktopRoutes: payload.desktopRoutes?.length || 0,
124
+ };
125
+ }
126
+
127
+ export default function MigrationPage() {
128
+ const ctx = useFlowContext();
129
+ const t = useT();
130
+ const api = ctx.api;
131
+ const [activeTab, setActiveTab] = useState('export');
132
+ const [availableItems, setAvailableItems] = useState<ItemsShape>({ collections: [], workflows: [], uiSchemas: [] });
133
+ const [selectedCollections, setSelectedCollections] = useState<React.Key[]>([]);
134
+ const [selectedWorkflows, setSelectedWorkflows] = useState<React.Key[]>([]);
135
+ const [selectedSchemas, setSelectedSchemas] = useState<React.Key[]>([]);
136
+ const [restarting, setRestarting] = useState(false);
137
+ const [applying, setApplying] = useState(false);
138
+ const [lastImportPayload, setLastImportPayload] = useState<MigrationPayload | null>(null);
139
+ const [lastWasPreview, setLastWasPreview] = useState(false);
140
+ const [collectionQuery, setCollectionQuery] = useState('');
141
+ const [workflowQuery, setWorkflowQuery] = useState('');
142
+ const [schemaQuery, setSchemaQuery] = useState('');
143
+ const [collectionPage, setCollectionPage] = useState({ current: 1, pageSize: 10 });
144
+ const [workflowPage, setWorkflowPage] = useState({ current: 1, pageSize: 10 });
145
+ const [schemaPage, setSchemaPage] = useState({ current: 1, pageSize: 10 });
146
+
147
+ useEffect(() => {
148
+ const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
149
+ if (restarting || applying) {
150
+ event.preventDefault();
151
+ event.returnValue = '';
152
+ }
153
+ };
154
+
155
+ window.addEventListener('beforeunload', beforeUnloadHandler);
156
+ return () => window.removeEventListener('beforeunload', beforeUnloadHandler);
157
+ }, [restarting, applying]);
158
+
159
+ const { run: fetchItems, loading: loadingItems } = useRequest(
160
+ () => api.request({ url: 'migration:list', method: 'get' }),
161
+ {
162
+ onSuccess: (response: WrappedResponse<ItemsShape>) => {
163
+ const payload = unwrapResponse<ItemsShape>(response);
164
+ setAvailableItems({
165
+ collections: payload?.collections ?? [],
166
+ workflows: payload?.workflows ?? [],
167
+ uiSchemas: payload?.uiSchemas ?? [],
168
+ });
169
+ },
170
+ onError: () => setAvailableItems({ collections: [], workflows: [], uiSchemas: [] }),
171
+ },
172
+ );
173
+
174
+ const { run: exportData, loading: exporting } = useRequest(
175
+ (payload: MigrationPayload) =>
176
+ api.request({
177
+ url: 'migration:export',
178
+ method: 'post',
179
+ data: { data: payload },
180
+ }),
181
+ {
182
+ manual: true,
183
+ onSuccess: (response: WrappedResponse<MigrationPayload>) => {
184
+ const payload = unwrapResponse<MigrationPayload>(response);
185
+ const counts = getCounts(payload || {});
186
+ const hasData =
187
+ counts.collections > 0 || counts.workflows > 0 || counts.uiSchemas > 0 || counts.desktopRoutes > 0;
188
+
189
+ if (!hasData) {
190
+ message.warning(t('Export succeeded but no data.'));
191
+ return;
192
+ }
193
+
194
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
195
+ const url = URL.createObjectURL(blob);
196
+ const anchor = document.createElement('a');
197
+ anchor.href = url;
198
+ anchor.download = `nocobase-migration-${Date.now()}.json`;
199
+ anchor.click();
200
+ URL.revokeObjectURL(url);
201
+ message.success(
202
+ t(
203
+ 'Export successful! {{collections}} collections, {{workflows}} workflows, {{uiSchemas}} UI schemas, {{routes}} routes',
204
+ {
205
+ collections: counts.collections,
206
+ workflows: counts.workflows,
207
+ uiSchemas: counts.uiSchemas,
208
+ routes: counts.desktopRoutes,
209
+ },
210
+ ),
211
+ );
212
+ },
213
+ onError: (error: unknown) =>
214
+ message.error(t('Export failed: {{message}}', { message: getErrorMessage(error, t('Unknown error')) })),
215
+ },
216
+ );
217
+
218
+ const triggerRestart = async (silentWaitMs = 10000, extraWaitMs = 2000) => {
219
+ setRestarting(true);
220
+ try {
221
+ await api.request({ url: 'app:restart', method: 'post', timeout: 60000 });
222
+ await delay(silentWaitMs);
223
+ await delay(extraWaitMs);
224
+ } finally {
225
+ setRestarting(false);
226
+ }
227
+ };
228
+
229
+ const applyWithRetry = async (collectionsPayload: unknown[], attempts = 15, intervalMs = 1500) => {
230
+ let lastError: unknown = null;
231
+
232
+ for (let index = 0; index < attempts; index += 1) {
233
+ try {
234
+ const response = await api.request({
235
+ url: 'migration:apply',
236
+ method: 'post',
237
+ data: { data: { collections: collectionsPayload } },
238
+ headers: { 'Content-Type': 'application/json' },
239
+ timeout: 600000,
240
+ });
241
+ return unwrapResponse<{ results?: { errors?: ErrorItem[] } }>(response);
242
+ } catch (error) {
243
+ lastError = error;
244
+ const status = isRecord(error) && isRecord(error.response) ? error.response.status : undefined;
245
+ const errorMessage = getErrorMessage(error, '');
246
+ if (status === 503 || errorMessage.includes('APP_COMMANDING')) {
247
+ await delay(intervalMs);
248
+ continue;
249
+ }
250
+ throw error;
251
+ }
252
+ }
253
+
254
+ throw lastError || new Error(t('Apply failed after several attempts.'));
255
+ };
256
+
257
+ const { run: runImport, loading: importing } = useRequest(
258
+ (payloadWithOptions: MigrationPayload) =>
259
+ api.request({
260
+ url: 'migration:import',
261
+ method: 'post',
262
+ data: { data: payloadWithOptions },
263
+ headers: { 'Content-Type': 'application/json' },
264
+ timeout: 600000,
265
+ }),
266
+ {
267
+ manual: true,
268
+ onSuccess: async (response: WrappedResponse<{ needsConfirmation?: boolean; results?: ImportResults }>) => {
269
+ const data = unwrapResponse<{ needsConfirmation?: boolean; results?: ImportResults }>(response);
270
+ const { needsConfirmation, results } = data || {};
271
+
272
+ if (needsConfirmation) {
273
+ const conflicts = results?.collections?.conflicts || [];
274
+ Modal.confirm({
275
+ title: t('Confirm Potentially Data-Altering Changes'),
276
+ icon: <ExclamationCircleOutlined />,
277
+ width: 760,
278
+ okText: t('Proceed & Override'),
279
+ cancelText: t('Cancel'),
280
+ content: (
281
+ <div>
282
+ <Alert
283
+ type="warning"
284
+ showIcon
285
+ message={t('Conflicts were found on existing fields.')}
286
+ style={{ marginBottom: 12 }}
287
+ />
288
+ <div style={{ maxHeight: 360, overflow: 'auto' }}>
289
+ <Table<ConflictRow>
290
+ size="small"
291
+ pagination={false}
292
+ rowKey={(record) => `${record.collection}.${record.field}`}
293
+ dataSource={conflicts}
294
+ columns={[
295
+ { title: t('Collection'), dataIndex: 'collection' },
296
+ { title: t('Field'), dataIndex: 'field' },
297
+ {
298
+ title: t('Current'),
299
+ render: (_, record) =>
300
+ `type=${getDisplayText(record.current.type)}, interface=${getDisplayText(
301
+ record.current.interface,
302
+ )}, unique=${getDisplayText(record.current.unique)}, allowNull=${getDisplayText(
303
+ record.current.allowNull,
304
+ )}, pk=${getDisplayText(record.current.primaryKey)}`,
305
+ },
306
+ {
307
+ title: t('Incoming'),
308
+ render: (_, record) =>
309
+ `type=${getDisplayText(record.incoming.type)}, interface=${getDisplayText(
310
+ record.incoming.interface,
311
+ )}, unique=${getDisplayText(record.incoming.unique)}, allowNull=${getDisplayText(
312
+ record.incoming.allowNull,
313
+ )}, pk=${getDisplayText(record.incoming.primaryKey)}`,
314
+ },
315
+ ]}
316
+ />
317
+ </div>
318
+ </div>
319
+ ),
320
+ onOk: () => {
321
+ if (!lastImportPayload) {
322
+ return;
323
+ }
324
+ setLastWasPreview(false);
325
+ runImport({ ...lastImportPayload, options: { forceOverride: true } });
326
+ },
327
+ });
328
+ return;
329
+ }
330
+
331
+ if (lastWasPreview && lastImportPayload) {
332
+ setLastWasPreview(false);
333
+ runImport(lastImportPayload);
334
+ return;
335
+ }
336
+
337
+ const allErrors = [
338
+ ...(results?.collections?.errors || []),
339
+ ...(results?.workflows?.errors || []),
340
+ ...(results?.uiSchemas?.errors || []),
341
+ ];
342
+
343
+ Modal.success({
344
+ title: t('Import Successful'),
345
+ content: (
346
+ <div>
347
+ <p>
348
+ {t('Collections')}: {results?.collections?.success || 0} {t('succeeded')},{' '}
349
+ {results?.collections?.failed || 0} {t('failed')}
350
+ </p>
351
+ <p>
352
+ {t('Workflows')}: {results?.workflows?.success || 0} {t('succeeded')}, {results?.workflows?.failed || 0}{' '}
353
+ {t('failed')}
354
+ </p>
355
+ <p>
356
+ {t('UI Schemas')}: {results?.uiSchemas?.success || 0} {t('succeeded')},{' '}
357
+ {results?.uiSchemas?.failed || 0} {t('failed')}
358
+ </p>
359
+ {allErrors.length > 0 && (
360
+ <Alert
361
+ message={t('Some errors occurred')}
362
+ description={
363
+ <ul>
364
+ {allErrors.map((error, index) => (
365
+ <li key={index}>{error.error || String(error)}</li>
366
+ ))}
367
+ </ul>
368
+ }
369
+ type="warning"
370
+ style={{ marginTop: 10 }}
371
+ />
372
+ )}
373
+ </div>
374
+ ),
375
+ okText: t('Continue'),
376
+ onOk: async () => {
377
+ try {
378
+ setApplying(true);
379
+ await triggerRestart(12000, 2000);
380
+ const collectionsPayload = (lastImportPayload?.collections || []).map((collection) =>
381
+ typeof collection === 'string' ? { name: collection } : collection,
382
+ );
383
+ const applyData = await applyWithRetry(collectionsPayload, 15, 1500);
384
+
385
+ Modal.success({
386
+ title: t('Apply Complete'),
387
+ content: (
388
+ <div>
389
+ <p>
390
+ {t(
391
+ 'The changes have been successfully applied. Click Continue to refresh the page for the changes to take effect.',
392
+ )}
393
+ </p>
394
+ {(applyData?.results?.errors?.length || 0) > 0 && (
395
+ <Alert
396
+ type="warning"
397
+ message={t('Errors occurred during apply')}
398
+ description={
399
+ <ul style={{ marginBottom: 0 }}>
400
+ {(applyData?.results?.errors || []).map((error, index) => (
401
+ <li key={index}>
402
+ {error.collection || error.route || error.schema || t('item')}: {error.error}
403
+ </li>
404
+ ))}
405
+ </ul>
406
+ }
407
+ />
408
+ )}
409
+ </div>
410
+ ),
411
+ okText: t('Continue'),
412
+ onOk: () => {
413
+ window.location.reload();
414
+ },
415
+ });
416
+ } catch (error) {
417
+ message.error(getErrorMessage(error, t('Apply failed')));
418
+ } finally {
419
+ setApplying(false);
420
+ setLastImportPayload(null);
421
+ fetchItems();
422
+ }
423
+ },
424
+ });
425
+ fetchItems();
426
+ },
427
+ onError: (error: unknown) =>
428
+ message.error(t('Import failed: {{message}}', { message: getErrorMessage(error, t('Unknown error')) })),
429
+ },
430
+ );
431
+
432
+ const handleExport = () => {
433
+ if (!selectedCollections.length && !selectedWorkflows.length && !selectedSchemas.length) {
434
+ message.warning(t('Select at least one item to export'));
435
+ return;
436
+ }
437
+
438
+ Modal.confirm({
439
+ title: t('Export Confirmation'),
440
+ icon: <ExclamationCircleOutlined />,
441
+ content: (
442
+ <div>
443
+ <p>{t('You will export:')}</p>
444
+ <ul>
445
+ <li>
446
+ {selectedCollections.length} {t('Collections')}
447
+ </li>
448
+ <li>
449
+ {selectedWorkflows.length} {t('Workflows')}
450
+ </li>
451
+ <li>
452
+ {selectedSchemas.length} {t('UI Schemas/Routes')}
453
+ </li>
454
+ </ul>
455
+ <Alert
456
+ message={t('Note')}
457
+ description={t(
458
+ 'Export includes collection structure, workflow config, UI schema subtree, and desktop routes.',
459
+ )}
460
+ type="info"
461
+ style={{ marginTop: 10 }}
462
+ />
463
+ </div>
464
+ ),
465
+ onOk: () => {
466
+ exportData({
467
+ collections: selectedCollections.map(String),
468
+ workflows: selectedWorkflows,
469
+ uiSchemas: [],
470
+ desktopRoutes: selectedSchemas.map(String),
471
+ });
472
+ },
473
+ });
474
+ };
475
+
476
+ const handleImport = (file: File) => {
477
+ const reader = new FileReader();
478
+ reader.onload = (event) => {
479
+ try {
480
+ const payload = JSON.parse(String(event.target?.result || '{}')) as MigrationPayload;
481
+ const counts = getCounts(payload);
482
+ setLastImportPayload(payload);
483
+ setLastWasPreview(true);
484
+ Modal.confirm({
485
+ title: t('Import Confirmation'),
486
+ icon: <ExclamationCircleOutlined />,
487
+ content: (
488
+ <div>
489
+ <p>{t('The file will import:')}</p>
490
+ <ul>
491
+ <li>
492
+ {counts.collections} {t('Collections')}
493
+ </li>
494
+ <li>
495
+ {counts.workflows} {t('Workflows')}
496
+ </li>
497
+ <li>
498
+ {counts.uiSchemas} {t('UI Schemas')}
499
+ </li>
500
+ </ul>
501
+ <Alert
502
+ message={t('Warning')}
503
+ description={t('The first step will run a PREVIEW. If safe, the process will proceed automatically.')}
504
+ type="warning"
505
+ style={{ marginTop: 10 }}
506
+ />
507
+ </div>
508
+ ),
509
+ onOk: () => runImport({ ...payload, options: { preview: true } }),
510
+ });
511
+ } catch {
512
+ message.error(t('Invalid or corrupt file'));
513
+ }
514
+ };
515
+ reader.readAsText(file);
516
+ return false;
517
+ };
518
+
519
+ const filteredCollections = useMemo(() => {
520
+ const query = collectionQuery.trim().toLowerCase();
521
+ const data = availableItems.collections.filter((collection) =>
522
+ (collection.title || collection.name || '').toLowerCase().includes(query),
523
+ );
524
+ return { total: data.length, data };
525
+ }, [availableItems.collections, collectionQuery]);
526
+
527
+ const pagedCollections = useMemo(() => {
528
+ const start = (collectionPage.current - 1) * collectionPage.pageSize;
529
+ return filteredCollections.data.slice(start, start + collectionPage.pageSize);
530
+ }, [collectionPage, filteredCollections]);
531
+
532
+ const filteredWorkflows = useMemo(() => {
533
+ const query = workflowQuery.trim().toLowerCase();
534
+ const data = availableItems.workflows.filter((workflow) => (workflow.title || '').toLowerCase().includes(query));
535
+ return { total: data.length, data };
536
+ }, [availableItems.workflows, workflowQuery]);
537
+
538
+ const pagedWorkflows = useMemo(() => {
539
+ const start = (workflowPage.current - 1) * workflowPage.pageSize;
540
+ return filteredWorkflows.data.slice(start, start + workflowPage.pageSize);
541
+ }, [filteredWorkflows, workflowPage]);
542
+
543
+ const filteredSchemas = useMemo(() => {
544
+ const query = schemaQuery.trim().toLowerCase();
545
+ const data = availableItems.uiSchemas.filter((schema) =>
546
+ (schema.displayTitle || schema.title || schema.name || '').toLowerCase().includes(query),
547
+ );
548
+ return { total: data.length, data };
549
+ }, [availableItems.uiSchemas, schemaQuery]);
550
+
551
+ const pagedSchemas = useMemo(() => {
552
+ const start = (schemaPage.current - 1) * schemaPage.pageSize;
553
+ return filteredSchemas.data.slice(start, start + schemaPage.pageSize);
554
+ }, [filteredSchemas, schemaPage]);
555
+
556
+ const isBusy = restarting || applying;
557
+
558
+ return (
559
+ <div style={{ padding: 24, position: 'relative' }}>
560
+ <Card>
561
+ <Title level={2}>{t('Migration Manager')}</Title>
562
+ <Text type="secondary">
563
+ {t('Export and import collections, workflows, and UI schemas across NocoBase instances')}
564
+ </Text>
565
+
566
+ <Tabs
567
+ activeKey={activeTab}
568
+ onChange={setActiveTab}
569
+ items={[
570
+ {
571
+ key: 'export',
572
+ label: t('Export'),
573
+ children: (
574
+ <Space direction="vertical" size="large" style={{ width: '100%' }}>
575
+ <Card
576
+ title={
577
+ <Space>
578
+ <DatabaseOutlined />
579
+ {t('Collections')}
580
+ </Space>
581
+ }
582
+ size="small"
583
+ >
584
+ <div style={{ marginBottom: 12 }}>
585
+ <Search
586
+ allowClear
587
+ placeholder={t('Filter by Title')}
588
+ onSearch={(value) => {
589
+ setCollectionQuery(value);
590
+ setCollectionPage({ ...collectionPage, current: 1 });
591
+ }}
592
+ onChange={(event) => {
593
+ setCollectionQuery(event.target.value);
594
+ setCollectionPage({ ...collectionPage, current: 1 });
595
+ }}
596
+ value={collectionQuery}
597
+ style={{ maxWidth: 320 }}
598
+ />
599
+ </div>
600
+ <Table<CollectionItem>
601
+ rowSelection={{
602
+ selectedRowKeys: selectedCollections,
603
+ onChange: setSelectedCollections,
604
+ }}
605
+ columns={[
606
+ { title: t('Name'), dataIndex: 'name', key: 'name' },
607
+ { title: t('Title'), dataIndex: 'title', key: 'title', render: prettifyCollectionTitle },
608
+ { title: t('Fields'), dataIndex: 'fields', key: 'fields' },
609
+ ]}
610
+ dataSource={pagedCollections}
611
+ rowKey="name"
612
+ loading={loadingItems}
613
+ pagination={{
614
+ current: collectionPage.current,
615
+ pageSize: collectionPage.pageSize,
616
+ total: filteredCollections.total,
617
+ showSizeChanger: true,
618
+ onChange: (current, pageSize) => setCollectionPage({ current, pageSize }),
619
+ }}
620
+ size="small"
621
+ />
622
+ </Card>
623
+
624
+ <Card
625
+ title={
626
+ <Space>
627
+ <BranchesOutlined />
628
+ {t('Workflows')}
629
+ </Space>
630
+ }
631
+ size="small"
632
+ >
633
+ <div style={{ marginBottom: 12 }}>
634
+ <Search
635
+ allowClear
636
+ placeholder={t('Filter by Title')}
637
+ onSearch={(value) => {
638
+ setWorkflowQuery(value);
639
+ setWorkflowPage({ ...workflowPage, current: 1 });
640
+ }}
641
+ onChange={(event) => {
642
+ setWorkflowQuery(event.target.value);
643
+ setWorkflowPage({ ...workflowPage, current: 1 });
644
+ }}
645
+ value={workflowQuery}
646
+ style={{ maxWidth: 320 }}
647
+ />
648
+ </div>
649
+ <Table<WorkflowItem>
650
+ rowSelection={{
651
+ selectedRowKeys: selectedWorkflows,
652
+ onChange: setSelectedWorkflows,
653
+ }}
654
+ columns={[
655
+ { title: t('Title'), dataIndex: 'title', key: 'title' },
656
+ { title: t('Key'), dataIndex: 'key', key: 'key' },
657
+ {
658
+ title: t('Status'),
659
+ dataIndex: 'enabled',
660
+ key: 'enabled',
661
+ render: (value: boolean) => (value ? t('Enabled') : t('Disabled')),
662
+ },
663
+ ]}
664
+ dataSource={pagedWorkflows}
665
+ rowKey="id"
666
+ loading={loadingItems}
667
+ pagination={{
668
+ current: workflowPage.current,
669
+ pageSize: workflowPage.pageSize,
670
+ total: filteredWorkflows.total,
671
+ showSizeChanger: true,
672
+ onChange: (current, pageSize) => setWorkflowPage({ current, pageSize }),
673
+ }}
674
+ size="small"
675
+ />
676
+ </Card>
677
+
678
+ <Card
679
+ title={
680
+ <Space>
681
+ <LayoutOutlined />
682
+ {t('UI Schemas')}
683
+ </Space>
684
+ }
685
+ size="small"
686
+ >
687
+ <div style={{ marginBottom: 12 }}>
688
+ <Search
689
+ allowClear
690
+ placeholder={t('Filter by Title')}
691
+ onSearch={(value) => {
692
+ setSchemaQuery(value);
693
+ setSchemaPage({ ...schemaPage, current: 1 });
694
+ }}
695
+ onChange={(event) => {
696
+ setSchemaQuery(event.target.value);
697
+ setSchemaPage({ ...schemaPage, current: 1 });
698
+ }}
699
+ value={schemaQuery}
700
+ style={{ maxWidth: 320 }}
701
+ />
702
+ </div>
703
+ <Table<UISchemaItem>
704
+ rowSelection={{
705
+ selectedRowKeys: selectedSchemas,
706
+ onChange: setSelectedSchemas,
707
+ }}
708
+ columns={[
709
+ {
710
+ title: t('Menu / Page'),
711
+ dataIndex: 'displayTitle',
712
+ key: 'displayTitle',
713
+ render: (_, record) => (
714
+ <Space>
715
+ <Text>{record?.displayTitle || record?.title || record?.name || t('Untitled')}</Text>
716
+ <Tag>{record?.type}</Tag>
717
+ </Space>
718
+ ),
719
+ },
720
+ { title: t('UID'), dataIndex: 'schemaUid', key: 'schemaUid' },
721
+ ]}
722
+ dataSource={pagedSchemas}
723
+ rowKey={(record) => record.schemaUid || record.uid || String(record.routeId || '')}
724
+ loading={loadingItems}
725
+ pagination={{
726
+ current: schemaPage.current,
727
+ pageSize: schemaPage.pageSize,
728
+ total: filteredSchemas.total,
729
+ showSizeChanger: true,
730
+ onChange: (current, pageSize) => setSchemaPage({ current, pageSize }),
731
+ }}
732
+ size="small"
733
+ />
734
+ </Card>
735
+
736
+ <Button
737
+ type="primary"
738
+ icon={<DownloadOutlined />}
739
+ onClick={handleExport}
740
+ loading={exporting}
741
+ size="large"
742
+ disabled={isBusy}
743
+ >
744
+ {t('Export Selected Items')}
745
+ </Button>
746
+ </Space>
747
+ ),
748
+ },
749
+ {
750
+ key: 'import',
751
+ label: t('Import'),
752
+ children: (
753
+ <Space direction="vertical" size="large" style={{ width: '100%' }}>
754
+ <Alert
755
+ message={t('Import from Development to Production')}
756
+ description={
757
+ <div>
758
+ <p>{t('Upload the exported JSON file from the development server.')}</p>
759
+ <p>
760
+ <strong>{t('What will be imported:')}</strong>
761
+ </p>
762
+ <ul>
763
+ <li>{t('Collection structure (without data)')}</li>
764
+ <li>{t('Workflow configuration')}</li>
765
+ <li>{t('UI Schema/Page design')}</li>
766
+ </ul>
767
+ <p>
768
+ <strong>{t('What will NOT be affected:')}</strong>
769
+ </p>
770
+ <ul>
771
+ <li>{t('Data within collections')}</li>
772
+ <li>{t('Collections not present in the import file')}</li>
773
+ </ul>
774
+ </div>
775
+ }
776
+ type="info"
777
+ />
778
+
779
+ <Upload accept=".json" beforeUpload={handleImport} showUploadList={false} disabled={isBusy}>
780
+ <Button type="primary" icon={<UploadOutlined />} loading={importing} size="large" disabled={isBusy}>
781
+ {t('Upload Migration File (.json)')}
782
+ </Button>
783
+ </Upload>
784
+ </Space>
785
+ ),
786
+ },
787
+ ]}
788
+ />
789
+ </Card>
790
+
791
+ {isBusy && (
792
+ <div
793
+ style={{
794
+ position: 'fixed',
795
+ inset: 0,
796
+ backgroundColor: 'rgba(255,255,255,0.8)',
797
+ display: 'flex',
798
+ alignItems: 'center',
799
+ justifyContent: 'center',
800
+ zIndex: 9999,
801
+ }}
802
+ >
803
+ <Spin size="large" tip={restarting ? t('Restarting...') : t('Applying...')} />
804
+ </div>
805
+ )}
806
+ </div>
807
+ );
808
+ }