strapi-content-sync-pro 1.0.5 → 1.0.7
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/LICENSE +1 -1
- package/README.md +1 -0
- package/admin/src/components/BulkTransferTab.jsx +185 -20
- package/admin/src/components/ContentTypesTab.jsx +28 -1
- package/admin/src/components/LogsTab.jsx +66 -8
- package/admin/src/components/MediaTab.jsx +113 -7
- package/admin/src/components/SyncProfilesTab.jsx +140 -4
- package/admin/src/components/SyncTab.jsx +161 -35
- package/docs/sync-strategy-approach-review.md +127 -0
- package/package.json +1 -1
- package/server/src/bootstrap.js +6 -0
- package/server/src/content-types/index.js +2 -0
- package/server/src/content-types/workflow-notification/schema.json +26 -0
- package/server/src/controllers/index.js +2 -0
- package/server/src/controllers/workflow-notifications.js +51 -0
- package/server/src/index.js +2 -0
- package/server/src/middlewares/index.js +13 -0
- package/server/src/middlewares/verify-signature.js +32 -32
- package/server/src/routes/index.js +10 -3
- package/server/src/services/bulk-transfer.js +45 -1
- package/server/src/services/dependency-resolver.js +37 -0
- package/server/src/services/index.js +2 -0
- package/server/src/services/sync-execution.js +21 -9
- package/server/src/services/sync-profiles.js +36 -15
- package/server/src/services/sync.js +234 -134
- package/server/src/services/workflow-notifications.js +145 -0
- package/server/src/utils/fetcher.js +7 -0
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2024 Ejaz
|
|
3
|
+
Copyright (c) 2024–2025 Ejaz Hussain Arain. All rights reserved.
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
Checkbox,
|
|
9
9
|
SingleSelect,
|
|
10
10
|
SingleSelectOption,
|
|
11
|
+
TextInput,
|
|
11
12
|
Field,
|
|
12
13
|
Table,
|
|
13
14
|
Thead,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
Badge,
|
|
19
20
|
Loader,
|
|
20
21
|
} from '@strapi/design-system';
|
|
22
|
+
import { CaretUp, CaretDown } from '@strapi/icons';
|
|
21
23
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
22
24
|
|
|
23
25
|
const PLUGIN_ID = 'strapi-content-sync-pro';
|
|
@@ -66,6 +68,18 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
66
68
|
// 'history' shows the persisted previous-runs table.
|
|
67
69
|
const [subTab, setSubTab] = useState('run');
|
|
68
70
|
|
|
71
|
+
// Chunk table filters + sort
|
|
72
|
+
const [chunkSearch, setChunkSearch] = useState('');
|
|
73
|
+
const [chunkKindFilter, setChunkKindFilter] = useState('');
|
|
74
|
+
const [chunkStatusFilter, setChunkStatusFilter] = useState('');
|
|
75
|
+
const [chunkSortField, setChunkSortField] = useState('');
|
|
76
|
+
const [chunkSortDir, setChunkSortDir] = useState('asc');
|
|
77
|
+
|
|
78
|
+
// History table filters + sort
|
|
79
|
+
const [historySearch, setHistorySearch] = useState('');
|
|
80
|
+
const [historySortField, setHistorySortField] = useState('');
|
|
81
|
+
const [historySortDir, setHistorySortDir] = useState('asc');
|
|
82
|
+
|
|
69
83
|
const pollRef = useRef(null);
|
|
70
84
|
|
|
71
85
|
const scopeCount = Object.values(scopes).filter(Boolean).length;
|
|
@@ -354,6 +368,77 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
354
368
|
const isActive = isRunning || isPaused;
|
|
355
369
|
const isTerminal = job && ['success', 'partial', 'cancelled', 'error'].includes(job.status);
|
|
356
370
|
|
|
371
|
+
// Derived chunk list (filter + sort)
|
|
372
|
+
const displayedChunks = useMemo(() => {
|
|
373
|
+
let result = [...chunkRows];
|
|
374
|
+
if (chunkSearch.trim()) {
|
|
375
|
+
const q = chunkSearch.trim().toLowerCase();
|
|
376
|
+
result = result.filter(
|
|
377
|
+
(c) => (c.label || '').toLowerCase().includes(q) || (c.kind || '').toLowerCase().includes(q)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (chunkKindFilter) {
|
|
381
|
+
result = result.filter((c) => (c.kind || '') === chunkKindFilter);
|
|
382
|
+
}
|
|
383
|
+
if (chunkStatusFilter) {
|
|
384
|
+
result = result.filter((c) => (c.status || '') === chunkStatusFilter);
|
|
385
|
+
}
|
|
386
|
+
if (chunkSortField) {
|
|
387
|
+
result.sort((a, b) => {
|
|
388
|
+
const aVal = a[chunkSortField] ?? '';
|
|
389
|
+
const bVal = b[chunkSortField] ?? '';
|
|
390
|
+
if (typeof aVal === 'string') {
|
|
391
|
+
return chunkSortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
392
|
+
}
|
|
393
|
+
return chunkSortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return result;
|
|
397
|
+
}, [chunkRows, chunkSearch, chunkKindFilter, chunkStatusFilter, chunkSortField, chunkSortDir]);
|
|
398
|
+
|
|
399
|
+
// Derived history list (filter + sort)
|
|
400
|
+
const displayedHistory = useMemo(() => {
|
|
401
|
+
let result = [...history];
|
|
402
|
+
if (historySearch.trim()) {
|
|
403
|
+
const q = historySearch.trim().toLowerCase();
|
|
404
|
+
result = result.filter((h) => (h.direction || '').toLowerCase().includes(q) || (h.status || '').toLowerCase().includes(q));
|
|
405
|
+
}
|
|
406
|
+
if (historySortField) {
|
|
407
|
+
result.sort((a, b) => {
|
|
408
|
+
const aVal = a[historySortField] ?? '';
|
|
409
|
+
const bVal = b[historySortField] ?? '';
|
|
410
|
+
if (typeof aVal === 'string') {
|
|
411
|
+
return historySortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
412
|
+
}
|
|
413
|
+
return historySortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return result;
|
|
417
|
+
}, [history, historySearch, historySortField, historySortDir]);
|
|
418
|
+
|
|
419
|
+
const handleChunkSort = (field) => {
|
|
420
|
+
if (chunkSortField === field) setChunkSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
421
|
+
else { setChunkSortField(field); setChunkSortDir('asc'); }
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const handleHistorySort = (field) => {
|
|
425
|
+
if (historySortField === field) setHistorySortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
426
|
+
else { setHistorySortField(field); setHistorySortDir('asc'); }
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const SortableTh = ({ field, onSort, sortF, sortD, style, children }) => (
|
|
430
|
+
<Th onClick={() => onSort(field)} style={{ cursor: 'pointer', userSelect: 'none', ...style }}>
|
|
431
|
+
<Flex alignItems="center" gap={1}>
|
|
432
|
+
<Typography variant="sigma">{children}</Typography>
|
|
433
|
+
{sortF === field && (sortD === 'asc' ? <CaretUp /> : <CaretDown />)}
|
|
434
|
+
</Flex>
|
|
435
|
+
</Th>
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
// Chunk kind options from current rows
|
|
439
|
+
const chunkKindOptions = useMemo(() => [...new Set(chunkRows.map((c) => c.kind).filter(Boolean))], [chunkRows]);
|
|
440
|
+
const chunkStatusOptions = useMemo(() => [...new Set(chunkRows.map((c) => c.status).filter(Boolean))], [chunkRows]);
|
|
441
|
+
|
|
357
442
|
const jobStats = useMemo(() => {
|
|
358
443
|
if (!job) return null;
|
|
359
444
|
const totals = (job.chunks || []).reduce(
|
|
@@ -605,6 +690,56 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
605
690
|
{job ? `Chunks (${job.cursor}/${job.total})` : `Planned Chunks (${preview?.total || chunkRows.length})`}
|
|
606
691
|
</Typography>
|
|
607
692
|
<Box paddingTop={2}>
|
|
693
|
+
{/* Chunk filter bar */}
|
|
694
|
+
<Flex gap={2} wrap="wrap" marginBottom={2} alignItems="flex-end">
|
|
695
|
+
<Box style={{ flex: '1 1 180px', minWidth: 150 }}>
|
|
696
|
+
<TextInput
|
|
697
|
+
placeholder="Search target or kind…"
|
|
698
|
+
value={chunkSearch}
|
|
699
|
+
onChange={(e) => setChunkSearch(e.target.value)}
|
|
700
|
+
label="Search"
|
|
701
|
+
size="S"
|
|
702
|
+
/>
|
|
703
|
+
</Box>
|
|
704
|
+
{chunkKindOptions.length > 1 && (
|
|
705
|
+
<Box style={{ minWidth: 130 }}>
|
|
706
|
+
<SingleSelect
|
|
707
|
+
placeholder="All kinds"
|
|
708
|
+
value={chunkKindFilter}
|
|
709
|
+
onChange={setChunkKindFilter}
|
|
710
|
+
onClear={() => setChunkKindFilter('')}
|
|
711
|
+
size="S"
|
|
712
|
+
label="Kind"
|
|
713
|
+
>
|
|
714
|
+
{chunkKindOptions.map((k) => (
|
|
715
|
+
<SingleSelectOption key={k} value={k}>{k}</SingleSelectOption>
|
|
716
|
+
))}
|
|
717
|
+
</SingleSelect>
|
|
718
|
+
</Box>
|
|
719
|
+
)}
|
|
720
|
+
{job && chunkStatusOptions.length > 1 && (
|
|
721
|
+
<Box style={{ minWidth: 130 }}>
|
|
722
|
+
<SingleSelect
|
|
723
|
+
placeholder="All statuses"
|
|
724
|
+
value={chunkStatusFilter}
|
|
725
|
+
onChange={setChunkStatusFilter}
|
|
726
|
+
onClear={() => setChunkStatusFilter('')}
|
|
727
|
+
size="S"
|
|
728
|
+
label="Status"
|
|
729
|
+
>
|
|
730
|
+
{chunkStatusOptions.map((s) => (
|
|
731
|
+
<SingleSelectOption key={s} value={s}>{s}</SingleSelectOption>
|
|
732
|
+
))}
|
|
733
|
+
</SingleSelect>
|
|
734
|
+
</Box>
|
|
735
|
+
)}
|
|
736
|
+
{(chunkSearch || chunkKindFilter || chunkStatusFilter) && (
|
|
737
|
+
<Button variant="tertiary" size="S" onClick={() => { setChunkSearch(''); setChunkKindFilter(''); setChunkStatusFilter(''); }}>
|
|
738
|
+
Clear
|
|
739
|
+
</Button>
|
|
740
|
+
)}
|
|
741
|
+
</Flex>
|
|
742
|
+
|
|
608
743
|
<Table>
|
|
609
744
|
<Thead>
|
|
610
745
|
<Tr>
|
|
@@ -618,17 +753,23 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
618
753
|
<Typography variant="sigma">Run</Typography>
|
|
619
754
|
)}
|
|
620
755
|
</Th>
|
|
621
|
-
<
|
|
622
|
-
<
|
|
623
|
-
<
|
|
624
|
-
<
|
|
756
|
+
<SortableTh field="index" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir} style={{ width: 60 }}>#</SortableTh>
|
|
757
|
+
<SortableTh field="kind" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir}>Kind</SortableTh>
|
|
758
|
+
<SortableTh field="label" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir}>Target</SortableTh>
|
|
759
|
+
<SortableTh field="status" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir}>Status</SortableTh>
|
|
625
760
|
<Th><Typography variant="sigma">Page</Typography></Th>
|
|
626
761
|
<Th><Typography variant="sigma">Pushed / Pulled</Typography></Th>
|
|
627
762
|
<Th><Typography variant="sigma">Notes</Typography></Th>
|
|
628
763
|
</Tr>
|
|
629
764
|
</Thead>
|
|
630
765
|
<Tbody>
|
|
631
|
-
{
|
|
766
|
+
{displayedChunks.length === 0 ? (
|
|
767
|
+
<Tr>
|
|
768
|
+
<Td colSpan={8}>
|
|
769
|
+
<Typography textColor="neutral500">No chunks match the current filters.</Typography>
|
|
770
|
+
</Td>
|
|
771
|
+
</Tr>
|
|
772
|
+
) : displayedChunks.map((c) => {
|
|
632
773
|
const pageLabel = c.pagesTotal
|
|
633
774
|
? `${c.page || 0}/${c.pagesTotal}`
|
|
634
775
|
: c.page
|
|
@@ -666,17 +807,17 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
666
807
|
<Td><Typography variant="pi">{pageLabel}</Typography></Td>
|
|
667
808
|
<Td><Typography variant="pi">{pushPullLabel}</Typography></Td>
|
|
668
809
|
<Td>
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
810
|
+
{c.error && <Typography textColor="danger600" variant="pi">{c.error}</Typography>}
|
|
811
|
+
{!c.error && c.warning && <Typography textColor="warning600" variant="pi">{c.warning}</Typography>}
|
|
812
|
+
</Td>
|
|
813
|
+
</Tr>
|
|
814
|
+
);
|
|
815
|
+
})}
|
|
816
|
+
</Tbody>
|
|
817
|
+
</Table>
|
|
818
|
+
</Box>
|
|
819
|
+
</Box>
|
|
820
|
+
)}
|
|
680
821
|
|
|
681
822
|
{job && job.status && job.status !== 'running' && (
|
|
682
823
|
<Box paddingTop={4}>
|
|
@@ -707,6 +848,24 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
707
848
|
Paused, cancelled, and completed runs are preserved here. Restart a run from scratch
|
|
708
849
|
using the same chunk selection, or load its selection into the Run Transfer tab to tweak.
|
|
709
850
|
</Typography>
|
|
851
|
+
|
|
852
|
+
{history.length > 0 && (
|
|
853
|
+
<Flex gap={2} wrap="wrap" marginTop={3} marginBottom={2} alignItems="flex-end">
|
|
854
|
+
<Box style={{ flex: '1 1 180px', minWidth: 150 }}>
|
|
855
|
+
<TextInput
|
|
856
|
+
placeholder="Search direction or status…"
|
|
857
|
+
value={historySearch}
|
|
858
|
+
onChange={(e) => setHistorySearch(e.target.value)}
|
|
859
|
+
label="Search"
|
|
860
|
+
size="S"
|
|
861
|
+
/>
|
|
862
|
+
</Box>
|
|
863
|
+
{historySearch && (
|
|
864
|
+
<Button variant="tertiary" size="S" onClick={() => setHistorySearch('')}>Clear</Button>
|
|
865
|
+
)}
|
|
866
|
+
</Flex>
|
|
867
|
+
)}
|
|
868
|
+
|
|
710
869
|
<Box paddingTop={2}>
|
|
711
870
|
{history.length === 0 ? (
|
|
712
871
|
<Typography variant="pi" textColor="neutral500">No previous runs yet.</Typography>
|
|
@@ -714,15 +873,21 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
714
873
|
<Table>
|
|
715
874
|
<Thead>
|
|
716
875
|
<Tr>
|
|
717
|
-
<
|
|
718
|
-
<
|
|
719
|
-
<
|
|
876
|
+
<SortableTh field="startedAt" onSort={handleHistorySort} sortF={historySortField} sortD={historySortDir}>When</SortableTh>
|
|
877
|
+
<SortableTh field="direction" onSort={handleHistorySort} sortF={historySortField} sortD={historySortDir}>Direction</SortableTh>
|
|
878
|
+
<SortableTh field="status" onSort={handleHistorySort} sortF={historySortField} sortD={historySortDir}>Status</SortableTh>
|
|
720
879
|
<Th><Typography variant="sigma">Chunks</Typography></Th>
|
|
721
880
|
<Th><Typography variant="sigma">Actions</Typography></Th>
|
|
722
881
|
</Tr>
|
|
723
882
|
</Thead>
|
|
724
883
|
<Tbody>
|
|
725
|
-
{
|
|
884
|
+
{displayedHistory.length === 0 ? (
|
|
885
|
+
<Tr>
|
|
886
|
+
<Td colSpan={5}>
|
|
887
|
+
<Typography textColor="neutral500">No runs match the search.</Typography>
|
|
888
|
+
</Td>
|
|
889
|
+
</Tr>
|
|
890
|
+
) : displayedHistory.map((h) => {
|
|
726
891
|
const selCount = (h.chunks || []).filter((c) => c.selected !== false).length;
|
|
727
892
|
const doneCount = (h.chunks || []).filter(
|
|
728
893
|
(c) => c.status === 'success' || c.status === 'partial'
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
Alert,
|
|
8
8
|
Switch,
|
|
9
9
|
Badge,
|
|
10
|
+
TextInput,
|
|
10
11
|
} from '@strapi/design-system';
|
|
11
12
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
12
13
|
|
|
@@ -20,6 +21,7 @@ const ContentTypesTab = () => {
|
|
|
20
21
|
const [profiles, setProfiles] = useState([]);
|
|
21
22
|
const [loading, setLoading] = useState(true);
|
|
22
23
|
const [message, setMessage] = useState(null);
|
|
24
|
+
const [search, setSearch] = useState('');
|
|
23
25
|
|
|
24
26
|
useEffect(() => {
|
|
25
27
|
loadData();
|
|
@@ -110,8 +112,25 @@ const ContentTypesTab = () => {
|
|
|
110
112
|
</Box>
|
|
111
113
|
)}
|
|
112
114
|
|
|
115
|
+
<Box paddingBottom={4}>
|
|
116
|
+
<TextInput
|
|
117
|
+
placeholder="Search by name or UID…"
|
|
118
|
+
value={search}
|
|
119
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
120
|
+
label="Search"
|
|
121
|
+
size="S"
|
|
122
|
+
style={{ maxWidth: 320 }}
|
|
123
|
+
/>
|
|
124
|
+
</Box>
|
|
125
|
+
|
|
113
126
|
<Box>
|
|
114
|
-
{contentTypes
|
|
127
|
+
{contentTypes
|
|
128
|
+
.filter((ct) => {
|
|
129
|
+
if (!search.trim()) return true;
|
|
130
|
+
const q = search.trim().toLowerCase();
|
|
131
|
+
return (ct.displayName || '').toLowerCase().includes(q) || ct.uid.toLowerCase().includes(q);
|
|
132
|
+
})
|
|
133
|
+
.map((ct) => {
|
|
115
134
|
const enabled = isEnabled(ct.uid);
|
|
116
135
|
const activeProfile = getActiveProfile(ct.uid);
|
|
117
136
|
const profileCount = getProfileCount(ct.uid);
|
|
@@ -151,6 +170,14 @@ const ContentTypesTab = () => {
|
|
|
151
170
|
</Box>
|
|
152
171
|
);
|
|
153
172
|
})}
|
|
173
|
+
{contentTypes.length > 0 && search.trim() && contentTypes.filter((ct) => {
|
|
174
|
+
const q = search.trim().toLowerCase();
|
|
175
|
+
return (ct.displayName || '').toLowerCase().includes(q) || ct.uid.toLowerCase().includes(q);
|
|
176
|
+
}).length === 0 && (
|
|
177
|
+
<Box padding={4} background="neutral0" hasRadius>
|
|
178
|
+
<Typography textColor="neutral500">No content types match the search.</Typography>
|
|
179
|
+
</Box>
|
|
180
|
+
)}
|
|
154
181
|
</Box>
|
|
155
182
|
</Box>
|
|
156
183
|
);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
Button,
|
|
7
7
|
SingleSelect,
|
|
8
8
|
SingleSelectOption,
|
|
9
|
+
TextInput,
|
|
9
10
|
Table,
|
|
10
11
|
Thead,
|
|
11
12
|
Tbody,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
Td,
|
|
14
15
|
Th,
|
|
15
16
|
} from '@strapi/design-system';
|
|
17
|
+
import { CaretUp, CaretDown } from '@strapi/icons';
|
|
16
18
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
17
19
|
|
|
18
20
|
const PLUGIN_ID = 'strapi-content-sync-pro';
|
|
@@ -24,6 +26,9 @@ const LogsTab = () => {
|
|
|
24
26
|
const [meta, setMeta] = useState(null);
|
|
25
27
|
const [page, setPage] = useState(1);
|
|
26
28
|
const [statusFilter, setStatusFilter] = useState('');
|
|
29
|
+
const [search, setSearch] = useState('');
|
|
30
|
+
const [sortField, setSortField] = useState('');
|
|
31
|
+
const [sortDir, setSortDir] = useState('asc');
|
|
27
32
|
const [loading, setLoading] = useState(true);
|
|
28
33
|
|
|
29
34
|
const fetchLogs = useCallback(async () => {
|
|
@@ -46,6 +51,48 @@ const LogsTab = () => {
|
|
|
46
51
|
fetchLogs();
|
|
47
52
|
}, [fetchLogs]);
|
|
48
53
|
|
|
54
|
+
const handleSort = (field) => {
|
|
55
|
+
if (sortField === field) {
|
|
56
|
+
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
57
|
+
} else {
|
|
58
|
+
setSortField(field);
|
|
59
|
+
setSortDir('asc');
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const displayedLogs = (() => {
|
|
64
|
+
let result = [...logs];
|
|
65
|
+
if (search.trim()) {
|
|
66
|
+
const q = search.trim().toLowerCase();
|
|
67
|
+
result = result.filter(
|
|
68
|
+
(l) =>
|
|
69
|
+
(l.action || '').toLowerCase().includes(q) ||
|
|
70
|
+
(l.contentType || '').toLowerCase().includes(q) ||
|
|
71
|
+
(l.message || '').toLowerCase().includes(q)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (sortField) {
|
|
75
|
+
result.sort((a, b) => {
|
|
76
|
+
const aVal = a[sortField] ?? '';
|
|
77
|
+
const bVal = b[sortField] ?? '';
|
|
78
|
+
if (typeof aVal === 'string') {
|
|
79
|
+
return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
80
|
+
}
|
|
81
|
+
return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
const SortableTh = ({ field, children }) => (
|
|
88
|
+
<Th onClick={() => handleSort(field)} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
|
89
|
+
<Flex alignItems="center" gap={1}>
|
|
90
|
+
<Typography variant="sigma">{children}</Typography>
|
|
91
|
+
{sortField === field && (sortDir === 'asc' ? <CaretUp /> : <CaretDown />)}
|
|
92
|
+
</Flex>
|
|
93
|
+
</Th>
|
|
94
|
+
);
|
|
95
|
+
|
|
49
96
|
return (
|
|
50
97
|
<Box>
|
|
51
98
|
<Flex justifyContent="space-between" alignItems="center">
|
|
@@ -65,19 +112,30 @@ const LogsTab = () => {
|
|
|
65
112
|
</Flex>
|
|
66
113
|
</Flex>
|
|
67
114
|
|
|
68
|
-
<Box paddingTop={
|
|
115
|
+
<Box paddingTop={3} paddingBottom={2}>
|
|
116
|
+
<TextInput
|
|
117
|
+
placeholder="Search action, content type, message…"
|
|
118
|
+
value={search}
|
|
119
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
120
|
+
label="Search"
|
|
121
|
+
size="S"
|
|
122
|
+
style={{ maxWidth: 340 }}
|
|
123
|
+
/>
|
|
124
|
+
</Box>
|
|
125
|
+
|
|
126
|
+
<Box paddingTop={2}>
|
|
69
127
|
<Table>
|
|
70
128
|
<Thead>
|
|
71
129
|
<Tr>
|
|
72
|
-
<
|
|
73
|
-
<
|
|
74
|
-
<
|
|
75
|
-
<
|
|
130
|
+
<SortableTh field="createdAt">Time</SortableTh>
|
|
131
|
+
<SortableTh field="action">Action</SortableTh>
|
|
132
|
+
<SortableTh field="contentType">Content Type</SortableTh>
|
|
133
|
+
<SortableTh field="status">Status</SortableTh>
|
|
76
134
|
<Th><Typography variant="sigma">Message</Typography></Th>
|
|
77
135
|
</Tr>
|
|
78
136
|
</Thead>
|
|
79
137
|
<Tbody>
|
|
80
|
-
{
|
|
138
|
+
{displayedLogs.map((log, i) => (
|
|
81
139
|
<Tr key={log.id || i}>
|
|
82
140
|
<Td><Typography>{new Date(log.createdAt).toLocaleString()}</Typography></Td>
|
|
83
141
|
<Td><Typography>{log.action}</Typography></Td>
|
|
@@ -96,11 +154,11 @@ const LogsTab = () => {
|
|
|
96
154
|
<Td><Typography>{log.message}</Typography></Td>
|
|
97
155
|
</Tr>
|
|
98
156
|
))}
|
|
99
|
-
{
|
|
157
|
+
{displayedLogs.length === 0 && (
|
|
100
158
|
<Tr>
|
|
101
159
|
<Td colSpan={5}>
|
|
102
160
|
<Typography textColor="neutral500">
|
|
103
|
-
{loading ? 'Loading…' : 'No logs found'}
|
|
161
|
+
{loading ? 'Loading…' : search ? 'No logs match the search.' : 'No logs found'}
|
|
104
162
|
</Typography>
|
|
105
163
|
</Td>
|
|
106
164
|
</Tr>
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
Dialog,
|
|
20
20
|
IconButton,
|
|
21
21
|
} from '@strapi/design-system';
|
|
22
|
-
import { Pencil, Trash, Play, Check, Stop } from '@strapi/icons';
|
|
22
|
+
import { Pencil, Trash, Play, Check, Stop, CaretUp, CaretDown } from '@strapi/icons';
|
|
23
23
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
24
24
|
|
|
25
25
|
const PLUGIN_ID = 'strapi-content-sync-pro';
|
|
@@ -103,6 +103,13 @@ const MediaTab = () => {
|
|
|
103
103
|
const [editProfile, setEditProfile] = useState(null);
|
|
104
104
|
const [editMode, setEditMode] = useState(null); // 'create' | 'edit'
|
|
105
105
|
|
|
106
|
+
// Profile list filter + sort
|
|
107
|
+
const [profileSearch, setProfileSearch] = useState('');
|
|
108
|
+
const [profileStrategyFilter, setProfileStrategyFilter] = useState('');
|
|
109
|
+
const [profileDirectionFilter, setProfileDirectionFilter] = useState('');
|
|
110
|
+
const [profileSortField, setProfileSortField] = useState('name');
|
|
111
|
+
const [profileSortDir, setProfileSortDir] = useState('asc');
|
|
112
|
+
|
|
106
113
|
const reload = async () => {
|
|
107
114
|
try {
|
|
108
115
|
const [pRes, gRes, sRes, dRes] = await Promise.all([
|
|
@@ -275,6 +282,51 @@ const MediaTab = () => {
|
|
|
275
282
|
const ep = editProfile || {};
|
|
276
283
|
const updateEp = (patch) => setEditProfile((p) => ({ ...p, ...patch }));
|
|
277
284
|
|
|
285
|
+
const handleProfileSort = (field) => {
|
|
286
|
+
if (profileSortField === field) {
|
|
287
|
+
setProfileSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
288
|
+
} else {
|
|
289
|
+
setProfileSortField(field);
|
|
290
|
+
setProfileSortDir('asc');
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const displayedProfiles = (() => {
|
|
295
|
+
let result = [...profiles];
|
|
296
|
+
if (profileSearch.trim()) {
|
|
297
|
+
const q = profileSearch.trim().toLowerCase();
|
|
298
|
+
result = result.filter((p) => (p.name || '').toLowerCase().includes(q));
|
|
299
|
+
}
|
|
300
|
+
if (profileStrategyFilter) {
|
|
301
|
+
result = result.filter((p) => p.strategy === profileStrategyFilter);
|
|
302
|
+
}
|
|
303
|
+
if (profileDirectionFilter) {
|
|
304
|
+
result = result.filter((p) => p.direction === profileDirectionFilter);
|
|
305
|
+
}
|
|
306
|
+
result.sort((a, b) => {
|
|
307
|
+
let aVal = a[profileSortField] ?? '';
|
|
308
|
+
let bVal = b[profileSortField] ?? '';
|
|
309
|
+
if (typeof aVal === 'boolean') { aVal = aVal ? 1 : 0; bVal = bVal ? 1 : 0; }
|
|
310
|
+
if (typeof aVal === 'string') {
|
|
311
|
+
return profileSortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
312
|
+
}
|
|
313
|
+
return profileSortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
314
|
+
});
|
|
315
|
+
return result;
|
|
316
|
+
})();
|
|
317
|
+
|
|
318
|
+
const SortableCol = ({ field, style, children }) => (
|
|
319
|
+
<Box
|
|
320
|
+
style={{ ...style, cursor: 'pointer', userSelect: 'none' }}
|
|
321
|
+
onClick={() => handleProfileSort(field)}
|
|
322
|
+
>
|
|
323
|
+
<Flex alignItems="center" gap={1}>
|
|
324
|
+
<Typography variant="sigma">{children}</Typography>
|
|
325
|
+
{profileSortField === field && (profileSortDir === 'asc' ? <CaretUp /> : <CaretDown />)}
|
|
326
|
+
</Flex>
|
|
327
|
+
</Box>
|
|
328
|
+
);
|
|
329
|
+
|
|
278
330
|
return (
|
|
279
331
|
<Box padding={4}>
|
|
280
332
|
<Box paddingBottom={4}>
|
|
@@ -317,17 +369,71 @@ const MediaTab = () => {
|
|
|
317
369
|
</Box>
|
|
318
370
|
) : (
|
|
319
371
|
<Box>
|
|
372
|
+
{/* Filter bar */}
|
|
373
|
+
<Flex gap={3} wrap="wrap" marginBottom={3} alignItems="flex-end">
|
|
374
|
+
<Box style={{ flex: '1 1 180px', minWidth: 160 }}>
|
|
375
|
+
<TextInput
|
|
376
|
+
placeholder="Search by name…"
|
|
377
|
+
value={profileSearch}
|
|
378
|
+
onChange={(e) => setProfileSearch(e.target.value)}
|
|
379
|
+
label="Search"
|
|
380
|
+
size="S"
|
|
381
|
+
/>
|
|
382
|
+
</Box>
|
|
383
|
+
<Box style={{ minWidth: 160 }}>
|
|
384
|
+
<SingleSelect
|
|
385
|
+
placeholder="All strategies"
|
|
386
|
+
value={profileStrategyFilter}
|
|
387
|
+
onChange={setProfileStrategyFilter}
|
|
388
|
+
onClear={() => setProfileStrategyFilter('')}
|
|
389
|
+
size="S"
|
|
390
|
+
label="Strategy"
|
|
391
|
+
>
|
|
392
|
+
<SingleSelectOption value="url">URL</SingleSelectOption>
|
|
393
|
+
<SingleSelectOption value="rsync">rsync</SingleSelectOption>
|
|
394
|
+
<SingleSelectOption value="disabled">Disabled</SingleSelectOption>
|
|
395
|
+
</SingleSelect>
|
|
396
|
+
</Box>
|
|
397
|
+
<Box style={{ minWidth: 160 }}>
|
|
398
|
+
<SingleSelect
|
|
399
|
+
placeholder="All directions"
|
|
400
|
+
value={profileDirectionFilter}
|
|
401
|
+
onChange={setProfileDirectionFilter}
|
|
402
|
+
onClear={() => setProfileDirectionFilter('')}
|
|
403
|
+
size="S"
|
|
404
|
+
label="Direction"
|
|
405
|
+
>
|
|
406
|
+
<SingleSelectOption value="push">Push</SingleSelectOption>
|
|
407
|
+
<SingleSelectOption value="pull">Pull</SingleSelectOption>
|
|
408
|
+
<SingleSelectOption value="both">Both</SingleSelectOption>
|
|
409
|
+
</SingleSelect>
|
|
410
|
+
</Box>
|
|
411
|
+
{(profileSearch || profileStrategyFilter || profileDirectionFilter) && (
|
|
412
|
+
<Button
|
|
413
|
+
variant="tertiary"
|
|
414
|
+
size="S"
|
|
415
|
+
onClick={() => { setProfileSearch(''); setProfileStrategyFilter(''); setProfileDirectionFilter(''); }}
|
|
416
|
+
>
|
|
417
|
+
Clear filters
|
|
418
|
+
</Button>
|
|
419
|
+
)}
|
|
420
|
+
</Flex>
|
|
421
|
+
|
|
320
422
|
{/* Header */}
|
|
321
423
|
<Flex background="neutral100" padding={3} hasRadius style={{ fontWeight: 600 }}>
|
|
322
|
-
<
|
|
323
|
-
<
|
|
324
|
-
<
|
|
325
|
-
<
|
|
326
|
-
<
|
|
424
|
+
<SortableCol field="name" style={{ flex: 2 }}>Name</SortableCol>
|
|
425
|
+
<SortableCol field="strategy" style={{ flex: 1 }}>Strategy</SortableCol>
|
|
426
|
+
<SortableCol field="direction" style={{ flex: 1 }}>Direction</SortableCol>
|
|
427
|
+
<SortableCol field="conflictStrategy" style={{ flex: 1 }}>Conflict</SortableCol>
|
|
428
|
+
<SortableCol field="executionMode" style={{ flex: 1 }}>Execution</SortableCol>
|
|
327
429
|
<Box style={{ flex: 1 }}><Typography variant="sigma">Sync Scope</Typography></Box>
|
|
328
430
|
<Box style={{ width: 180 }}><Typography variant="sigma">Actions</Typography></Box>
|
|
329
431
|
</Flex>
|
|
330
|
-
{
|
|
432
|
+
{displayedProfiles.length === 0 ? (
|
|
433
|
+
<Box padding={4}>
|
|
434
|
+
<Typography textColor="neutral500">No profiles match the current filters.</Typography>
|
|
435
|
+
</Box>
|
|
436
|
+
) : displayedProfiles.map((p) => (
|
|
331
437
|
<Flex key={p.id} padding={3} borderColor="neutral150" style={{ borderBottom: '1px solid #eee' }} alignItems="center">
|
|
332
438
|
<Box style={{ flex: 2 }}>
|
|
333
439
|
<Flex gap={2} alignItems="center">
|