strapi-content-sync-pro 1.0.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/LICENSE +21 -0
- package/README.md +206 -0
- package/admin/src/components/ConfigTab.jsx +1038 -0
- package/admin/src/components/ContentTypesTab.jsx +160 -0
- package/admin/src/components/HelpTab.jsx +945 -0
- package/admin/src/components/LogsTab.jsx +136 -0
- package/admin/src/components/MediaTab.jsx +557 -0
- package/admin/src/components/SyncProfilesTab.jsx +715 -0
- package/admin/src/components/SyncTab.jsx +988 -0
- package/admin/src/index.js +31 -0
- package/admin/src/pages/App/index.jsx +129 -0
- package/admin/src/pluginId.js +3 -0
- package/package.json +84 -0
- package/server/src/bootstrap.js +151 -0
- package/server/src/config/index.js +5 -0
- package/server/src/content-types/index.js +7 -0
- package/server/src/content-types/sync-log/schema.json +24 -0
- package/server/src/controllers/alerts.js +59 -0
- package/server/src/controllers/config.js +292 -0
- package/server/src/controllers/content-type-discovery.js +9 -0
- package/server/src/controllers/dependencies.js +109 -0
- package/server/src/controllers/index.js +29 -0
- package/server/src/controllers/ping.js +7 -0
- package/server/src/controllers/sync-config.js +26 -0
- package/server/src/controllers/sync-enforcement.js +323 -0
- package/server/src/controllers/sync-execution.js +134 -0
- package/server/src/controllers/sync-log.js +18 -0
- package/server/src/controllers/sync-media.js +158 -0
- package/server/src/controllers/sync-profiles.js +182 -0
- package/server/src/controllers/sync.js +31 -0
- package/server/src/destroy.js +7 -0
- package/server/src/index.js +21 -0
- package/server/src/middlewares/verify-signature.js +32 -0
- package/server/src/register.js +7 -0
- package/server/src/routes/index.js +111 -0
- package/server/src/services/alerts.js +437 -0
- package/server/src/services/config.js +68 -0
- package/server/src/services/content-type-discovery.js +41 -0
- package/server/src/services/dependency-resolver.js +284 -0
- package/server/src/services/index.js +30 -0
- package/server/src/services/ping.js +7 -0
- package/server/src/services/sync-config.js +45 -0
- package/server/src/services/sync-enforcement.js +362 -0
- package/server/src/services/sync-execution.js +541 -0
- package/server/src/services/sync-log.js +56 -0
- package/server/src/services/sync-media.js +963 -0
- package/server/src/services/sync-profiles.js +380 -0
- package/server/src/services/sync.js +248 -0
- package/server/src/utils/applier.js +89 -0
- package/server/src/utils/comparator.js +83 -0
- package/server/src/utils/fetcher.js +142 -0
- package/server/src/utils/hmac.js +37 -0
- package/server/src/utils/pagination.js +51 -0
- package/server/src/utils/sync-guard.js +29 -0
- package/server/src/utils/sync-id.js +16 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Flex,
|
|
5
|
+
Typography,
|
|
6
|
+
Button,
|
|
7
|
+
Alert,
|
|
8
|
+
TextInput,
|
|
9
|
+
SingleSelect,
|
|
10
|
+
SingleSelectOption,
|
|
11
|
+
Checkbox,
|
|
12
|
+
Field,
|
|
13
|
+
Modal,
|
|
14
|
+
IconButton,
|
|
15
|
+
Badge,
|
|
16
|
+
Table,
|
|
17
|
+
Thead,
|
|
18
|
+
Tbody,
|
|
19
|
+
Tr,
|
|
20
|
+
Th,
|
|
21
|
+
Td,
|
|
22
|
+
Tabs,
|
|
23
|
+
} from '@strapi/design-system';
|
|
24
|
+
import { Pencil, Trash, Plus, Check, CaretUp, CaretDown } from '@strapi/icons';
|
|
25
|
+
import { useFetchClient } from '@strapi/strapi/admin';
|
|
26
|
+
|
|
27
|
+
const PLUGIN_ID = 'strapi-content-sync-pro';
|
|
28
|
+
|
|
29
|
+
const DIRECTION_OPTIONS = [
|
|
30
|
+
{ value: 'push', label: 'Push Only' },
|
|
31
|
+
{ value: 'pull', label: 'Pull Only' },
|
|
32
|
+
{ value: 'both', label: 'Bidirectional' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const CONFLICT_STRATEGY_OPTIONS = [
|
|
36
|
+
{ value: 'latest', label: 'Latest Wins' },
|
|
37
|
+
{ value: 'local_wins', label: 'Local Wins' },
|
|
38
|
+
{ value: 'remote_wins', label: 'Remote Wins' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const FIELD_DIRECTION_OPTIONS = [
|
|
42
|
+
{ value: 'both', label: 'Both' },
|
|
43
|
+
{ value: 'push', label: 'Push' },
|
|
44
|
+
{ value: 'pull', label: 'Pull' },
|
|
45
|
+
{ value: 'none', label: 'Exclude' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const SIMPLE_PRESETS = [
|
|
49
|
+
{ value: 'full_push', label: 'Full Push', description: 'Push all data to remote' },
|
|
50
|
+
{ value: 'full_pull', label: 'Full Pull', description: 'Pull all data from remote' },
|
|
51
|
+
{ value: 'bidirectional', label: 'Bidirectional', description: 'Two-way sync' },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const SyncProfilesTab = () => {
|
|
55
|
+
const { get, post, put, del } = useFetchClient();
|
|
56
|
+
|
|
57
|
+
const [profiles, setProfiles] = useState([]);
|
|
58
|
+
const [contentTypes, setContentTypes] = useState([]);
|
|
59
|
+
const [enabledTypes, setEnabledTypes] = useState([]);
|
|
60
|
+
const [loading, setLoading] = useState(true);
|
|
61
|
+
const [message, setMessage] = useState(null);
|
|
62
|
+
|
|
63
|
+
// Sorting state
|
|
64
|
+
const [sortField, setSortField] = useState('name');
|
|
65
|
+
const [sortDirection, setSortDirection] = useState('asc');
|
|
66
|
+
|
|
67
|
+
// Selection state for bulk operations
|
|
68
|
+
const [selectedProfiles, setSelectedProfiles] = useState([]);
|
|
69
|
+
|
|
70
|
+
// Modal state
|
|
71
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
72
|
+
const [editingProfile, setEditingProfile] = useState(null);
|
|
73
|
+
const [createMode, setCreateMode] = useState('simple'); // 'simple' or 'advanced'
|
|
74
|
+
const [selectedPreset, setSelectedPreset] = useState('');
|
|
75
|
+
const [formData, setFormData] = useState({
|
|
76
|
+
name: '',
|
|
77
|
+
contentType: '',
|
|
78
|
+
direction: 'both',
|
|
79
|
+
conflictStrategy: 'latest',
|
|
80
|
+
isActive: false,
|
|
81
|
+
isSimple: true,
|
|
82
|
+
fieldPolicies: [],
|
|
83
|
+
});
|
|
84
|
+
const [schemaFields, setSchemaFields] = useState([]);
|
|
85
|
+
const [loadingSchema, setLoadingSchema] = useState(false);
|
|
86
|
+
|
|
87
|
+
// Sorted profiles
|
|
88
|
+
const sortedProfiles = useMemo(() => {
|
|
89
|
+
const sorted = [...profiles].sort((a, b) => {
|
|
90
|
+
let aVal = a[sortField];
|
|
91
|
+
let bVal = b[sortField];
|
|
92
|
+
|
|
93
|
+
// Handle content type display name
|
|
94
|
+
if (sortField === 'contentType') {
|
|
95
|
+
const ctA = contentTypes.find(ct => ct.uid === a.contentType);
|
|
96
|
+
const ctB = contentTypes.find(ct => ct.uid === b.contentType);
|
|
97
|
+
aVal = ctA?.displayName || a.contentType;
|
|
98
|
+
bVal = ctB?.displayName || b.contentType;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Handle boolean for isActive
|
|
102
|
+
if (sortField === 'isActive') {
|
|
103
|
+
aVal = a.isActive ? 1 : 0;
|
|
104
|
+
bVal = b.isActive ? 1 : 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof aVal === 'string') {
|
|
108
|
+
return sortDirection === 'asc'
|
|
109
|
+
? aVal.localeCompare(bVal)
|
|
110
|
+
: bVal.localeCompare(aVal);
|
|
111
|
+
}
|
|
112
|
+
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
|
|
113
|
+
});
|
|
114
|
+
return sorted;
|
|
115
|
+
}, [profiles, sortField, sortDirection, contentTypes]);
|
|
116
|
+
|
|
117
|
+
const handleSort = (field) => {
|
|
118
|
+
if (sortField === field) {
|
|
119
|
+
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
|
120
|
+
} else {
|
|
121
|
+
setSortField(field);
|
|
122
|
+
setSortDirection('asc');
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const SortableHeader = ({ field, children }) => (
|
|
127
|
+
<Th
|
|
128
|
+
onClick={() => handleSort(field)}
|
|
129
|
+
style={{ cursor: 'pointer', userSelect: 'none' }}
|
|
130
|
+
>
|
|
131
|
+
<Flex alignItems="center" gap={1}>
|
|
132
|
+
<Typography variant="sigma">{children}</Typography>
|
|
133
|
+
{sortField === field && (
|
|
134
|
+
sortDirection === 'asc' ? <CaretUp /> : <CaretDown />
|
|
135
|
+
)}
|
|
136
|
+
</Flex>
|
|
137
|
+
</Th>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const handleSelectProfile = (profileId) => {
|
|
141
|
+
setSelectedProfiles(prev =>
|
|
142
|
+
prev.includes(profileId)
|
|
143
|
+
? prev.filter(id => id !== profileId)
|
|
144
|
+
: [...prev, profileId]
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const handleSelectAll = () => {
|
|
149
|
+
if (selectedProfiles.length === profiles.length) {
|
|
150
|
+
setSelectedProfiles([]);
|
|
151
|
+
} else {
|
|
152
|
+
setSelectedProfiles(profiles.map(p => p.id));
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const handleBulkActivate = async () => {
|
|
157
|
+
if (selectedProfiles.length === 0) return;
|
|
158
|
+
try {
|
|
159
|
+
for (const profileId of selectedProfiles) {
|
|
160
|
+
await put(`/${PLUGIN_ID}/sync-profiles/${profileId}`, { isActive: true });
|
|
161
|
+
}
|
|
162
|
+
setMessage({ type: 'success', text: `Activated ${selectedProfiles.length} profiles` });
|
|
163
|
+
setSelectedProfiles([]);
|
|
164
|
+
loadData();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to activate profiles' });
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const handleBulkDeactivate = async () => {
|
|
171
|
+
if (selectedProfiles.length === 0) return;
|
|
172
|
+
try {
|
|
173
|
+
for (const profileId of selectedProfiles) {
|
|
174
|
+
await put(`/${PLUGIN_ID}/sync-profiles/${profileId}`, { isActive: false });
|
|
175
|
+
}
|
|
176
|
+
setMessage({ type: 'success', text: `Deactivated ${selectedProfiles.length} profiles` });
|
|
177
|
+
setSelectedProfiles([]);
|
|
178
|
+
loadData();
|
|
179
|
+
} catch (err) {
|
|
180
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to deactivate profiles' });
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const handleBulkDelete = async () => {
|
|
185
|
+
if (selectedProfiles.length === 0) return;
|
|
186
|
+
if (!window.confirm(`Delete ${selectedProfiles.length} selected profiles?`)) return;
|
|
187
|
+
try {
|
|
188
|
+
for (const profileId of selectedProfiles) {
|
|
189
|
+
await del(`/${PLUGIN_ID}/sync-profiles/${profileId}`);
|
|
190
|
+
}
|
|
191
|
+
setMessage({ type: 'success', text: `Deleted ${selectedProfiles.length} profiles` });
|
|
192
|
+
setSelectedProfiles([]);
|
|
193
|
+
loadData();
|
|
194
|
+
} catch (err) {
|
|
195
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to delete profiles' });
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
loadData();
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
const loadData = async () => {
|
|
204
|
+
try {
|
|
205
|
+
const [profilesRes, ctRes, scRes] = await Promise.all([
|
|
206
|
+
get(`/${PLUGIN_ID}/sync-profiles`),
|
|
207
|
+
get(`/${PLUGIN_ID}/content-types`),
|
|
208
|
+
get(`/${PLUGIN_ID}/sync-config`),
|
|
209
|
+
]);
|
|
210
|
+
setProfiles(profilesRes.data.data || []);
|
|
211
|
+
setContentTypes(ctRes.data.data || []);
|
|
212
|
+
const config = scRes.data.data || { contentTypes: [] };
|
|
213
|
+
setEnabledTypes(config.contentTypes?.filter(ct => ct.enabled).map(ct => ct.uid) || []);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error('Failed to load data', err);
|
|
216
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to load profiles' });
|
|
217
|
+
} finally {
|
|
218
|
+
setLoading(false);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const loadContentTypeSchema = async (uid) => {
|
|
223
|
+
if (!uid) {
|
|
224
|
+
setSchemaFields([]);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
setLoadingSchema(true);
|
|
228
|
+
try {
|
|
229
|
+
const res = await get(`/${PLUGIN_ID}/content-type-schema/${encodeURIComponent(uid)}`);
|
|
230
|
+
const fields = res.data.data?.fields || [];
|
|
231
|
+
setSchemaFields(fields);
|
|
232
|
+
|
|
233
|
+
// Initialize field policies for all fields with default 'both'
|
|
234
|
+
if (!editingProfile && createMode === 'advanced') {
|
|
235
|
+
setFormData((prev) => ({
|
|
236
|
+
...prev,
|
|
237
|
+
fieldPolicies: fields.map((f) => ({
|
|
238
|
+
field: f.name,
|
|
239
|
+
direction: 'both',
|
|
240
|
+
})),
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error('Failed to load schema', err);
|
|
245
|
+
setSchemaFields([]);
|
|
246
|
+
} finally {
|
|
247
|
+
setLoadingSchema(false);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const handleContentTypeChange = (uid) => {
|
|
252
|
+
setFormData((prev) => ({ ...prev, contentType: uid, fieldPolicies: [] }));
|
|
253
|
+
if (createMode === 'advanced') {
|
|
254
|
+
loadContentTypeSchema(uid);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const handleFieldPolicyChange = (fieldName, direction) => {
|
|
259
|
+
setFormData((prev) => {
|
|
260
|
+
const existing = prev.fieldPolicies.find((fp) => fp.field === fieldName);
|
|
261
|
+
if (existing) {
|
|
262
|
+
return {
|
|
263
|
+
...prev,
|
|
264
|
+
fieldPolicies: prev.fieldPolicies.map((fp) =>
|
|
265
|
+
fp.field === fieldName ? { ...fp, direction } : fp
|
|
266
|
+
),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
...prev,
|
|
271
|
+
fieldPolicies: [...prev.fieldPolicies, { field: fieldName, direction }],
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const getFieldPolicy = (fieldName) => {
|
|
277
|
+
const fp = formData.fieldPolicies.find((p) => p.field === fieldName);
|
|
278
|
+
return fp?.direction || 'both';
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const openCreateModal = () => {
|
|
282
|
+
setEditingProfile(null);
|
|
283
|
+
setCreateMode('simple');
|
|
284
|
+
setSelectedPreset('');
|
|
285
|
+
setFormData({
|
|
286
|
+
name: '',
|
|
287
|
+
contentType: '',
|
|
288
|
+
direction: 'both',
|
|
289
|
+
conflictStrategy: 'latest',
|
|
290
|
+
isActive: false,
|
|
291
|
+
isSimple: true,
|
|
292
|
+
fieldPolicies: [],
|
|
293
|
+
});
|
|
294
|
+
setSchemaFields([]);
|
|
295
|
+
setModalOpen(true);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const openEditModal = async (profile) => {
|
|
299
|
+
setEditingProfile(profile);
|
|
300
|
+
setCreateMode(profile.isSimple ? 'simple' : 'advanced');
|
|
301
|
+
setFormData({
|
|
302
|
+
name: profile.name,
|
|
303
|
+
contentType: profile.contentType,
|
|
304
|
+
direction: profile.direction || 'both',
|
|
305
|
+
conflictStrategy: profile.conflictStrategy || 'latest',
|
|
306
|
+
isActive: profile.isActive,
|
|
307
|
+
isSimple: profile.isSimple !== false,
|
|
308
|
+
fieldPolicies: profile.fieldPolicies || [],
|
|
309
|
+
});
|
|
310
|
+
if (!profile.isSimple) {
|
|
311
|
+
await loadContentTypeSchema(profile.contentType);
|
|
312
|
+
}
|
|
313
|
+
setModalOpen(true);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const handlePresetSelect = (preset) => {
|
|
317
|
+
setSelectedPreset(preset);
|
|
318
|
+
const presetConfig = {
|
|
319
|
+
full_push: { direction: 'push', conflictStrategy: 'local_wins' },
|
|
320
|
+
full_pull: { direction: 'pull', conflictStrategy: 'remote_wins' },
|
|
321
|
+
bidirectional: { direction: 'both', conflictStrategy: 'latest' },
|
|
322
|
+
};
|
|
323
|
+
const config = presetConfig[preset] || {};
|
|
324
|
+
setFormData((prev) => ({
|
|
325
|
+
...prev,
|
|
326
|
+
...config,
|
|
327
|
+
isSimple: true,
|
|
328
|
+
}));
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const handleModeChange = async (mode) => {
|
|
332
|
+
setCreateMode(mode);
|
|
333
|
+
setFormData((prev) => ({
|
|
334
|
+
...prev,
|
|
335
|
+
isSimple: mode === 'simple',
|
|
336
|
+
fieldPolicies: [],
|
|
337
|
+
}));
|
|
338
|
+
if (mode === 'advanced' && formData.contentType) {
|
|
339
|
+
await loadContentTypeSchema(formData.contentType);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const handleSave = async () => {
|
|
344
|
+
try {
|
|
345
|
+
const payload = {
|
|
346
|
+
...formData,
|
|
347
|
+
isSimple: createMode === 'simple',
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
if (editingProfile) {
|
|
351
|
+
await put(`/${PLUGIN_ID}/sync-profiles/${editingProfile.id}`, payload);
|
|
352
|
+
setMessage({ type: 'success', text: 'Profile updated successfully' });
|
|
353
|
+
} else {
|
|
354
|
+
await post(`/${PLUGIN_ID}/sync-profiles`, payload);
|
|
355
|
+
setMessage({ type: 'success', text: 'Profile created successfully' });
|
|
356
|
+
}
|
|
357
|
+
setModalOpen(false);
|
|
358
|
+
loadData();
|
|
359
|
+
} catch (err) {
|
|
360
|
+
setMessage({ type: 'danger', text: err.response?.data?.error?.message || 'Failed to save profile' });
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const handleDelete = async (id) => {
|
|
365
|
+
if (!window.confirm('Are you sure you want to delete this profile?')) return;
|
|
366
|
+
try {
|
|
367
|
+
await del(`/${PLUGIN_ID}/sync-profiles/${id}`);
|
|
368
|
+
setMessage({ type: 'success', text: 'Profile deleted' });
|
|
369
|
+
loadData();
|
|
370
|
+
} catch (err) {
|
|
371
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to delete profile' });
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const handleActivate = async (profile) => {
|
|
376
|
+
try {
|
|
377
|
+
await put(`/${PLUGIN_ID}/sync-profiles/${profile.id}`, { isActive: true });
|
|
378
|
+
setMessage({ type: 'success', text: `Activated: ${profile.name}` });
|
|
379
|
+
loadData();
|
|
380
|
+
} catch (err) {
|
|
381
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to activate profile' });
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const getContentTypeName = (uid) => {
|
|
386
|
+
const ct = contentTypes.find((c) => c.uid === uid);
|
|
387
|
+
return ct?.displayName || uid;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const getEnabledContentTypes = () => {
|
|
391
|
+
return contentTypes.filter((ct) => enabledTypes.includes(ct.uid));
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const getDirectionLabel = (direction) => {
|
|
395
|
+
return DIRECTION_OPTIONS.find(o => o.value === direction)?.label || direction;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
if (loading) return <Typography>Loading…</Typography>;
|
|
399
|
+
|
|
400
|
+
return (
|
|
401
|
+
<Box>
|
|
402
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
403
|
+
<Box>
|
|
404
|
+
<Typography variant="beta" tag="h2">Sync Profiles</Typography>
|
|
405
|
+
<Typography variant="omega" textColor="neutral600">
|
|
406
|
+
Configure sync behavior per content type including direction, conflict strategy, and field policies.
|
|
407
|
+
Execution timing (on-demand, scheduled, live) is configured in the Sync tab.
|
|
408
|
+
</Typography>
|
|
409
|
+
</Box>
|
|
410
|
+
<Button startIcon={<Plus />} onClick={openCreateModal}>
|
|
411
|
+
Create Profile
|
|
412
|
+
</Button>
|
|
413
|
+
</Flex>
|
|
414
|
+
|
|
415
|
+
{message && (
|
|
416
|
+
<Box paddingTop={4}>
|
|
417
|
+
<Alert variant={message.type} closeLabel="Close" onClose={() => setMessage(null)}>
|
|
418
|
+
{message.text}
|
|
419
|
+
</Alert>
|
|
420
|
+
</Box>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{/* Bulk Actions Bar */}
|
|
424
|
+
{selectedProfiles.length > 0 && (
|
|
425
|
+
<Box paddingTop={4}>
|
|
426
|
+
<Flex gap={2} alignItems="center" background="neutral100" padding={3} hasRadius>
|
|
427
|
+
<Typography variant="omega" fontWeight="bold">
|
|
428
|
+
{selectedProfiles.length} selected
|
|
429
|
+
</Typography>
|
|
430
|
+
<Button variant="success" size="S" onClick={handleBulkActivate}>
|
|
431
|
+
Activate Selected
|
|
432
|
+
</Button>
|
|
433
|
+
<Button variant="secondary" size="S" onClick={handleBulkDeactivate}>
|
|
434
|
+
Deactivate Selected
|
|
435
|
+
</Button>
|
|
436
|
+
<Button variant="danger" size="S" onClick={handleBulkDelete}>
|
|
437
|
+
Delete Selected
|
|
438
|
+
</Button>
|
|
439
|
+
<Button variant="tertiary" size="S" onClick={() => setSelectedProfiles([])}>
|
|
440
|
+
Clear Selection
|
|
441
|
+
</Button>
|
|
442
|
+
</Flex>
|
|
443
|
+
</Box>
|
|
444
|
+
)}
|
|
445
|
+
|
|
446
|
+
<Box paddingTop={4}>
|
|
447
|
+
{profiles.length === 0 ? (
|
|
448
|
+
<Box padding={6} background="neutral0" hasRadius>
|
|
449
|
+
<Typography textColor="neutral600">
|
|
450
|
+
No sync profiles found. Enable content types in the Content Types tab to auto-generate profiles,
|
|
451
|
+
or create a custom profile.
|
|
452
|
+
</Typography>
|
|
453
|
+
</Box>
|
|
454
|
+
) : (
|
|
455
|
+
<Table>
|
|
456
|
+
<Thead>
|
|
457
|
+
<Tr>
|
|
458
|
+
<Th>
|
|
459
|
+
<Checkbox
|
|
460
|
+
checked={selectedProfiles.length === profiles.length && profiles.length > 0}
|
|
461
|
+
indeterminate={selectedProfiles.length > 0 && selectedProfiles.length < profiles.length}
|
|
462
|
+
onCheckedChange={handleSelectAll}
|
|
463
|
+
aria-label="Select all profiles"
|
|
464
|
+
/>
|
|
465
|
+
</Th>
|
|
466
|
+
<SortableHeader field="name">Name</SortableHeader>
|
|
467
|
+
<SortableHeader field="contentType">Content Type</SortableHeader>
|
|
468
|
+
<SortableHeader field="direction">Direction</SortableHeader>
|
|
469
|
+
<SortableHeader field="conflictStrategy">Conflict</SortableHeader>
|
|
470
|
+
<SortableHeader field="isSimple">Mode</SortableHeader>
|
|
471
|
+
<SortableHeader field="isActive">Status</SortableHeader>
|
|
472
|
+
<Th><Typography variant="sigma">Actions</Typography></Th>
|
|
473
|
+
</Tr>
|
|
474
|
+
</Thead>
|
|
475
|
+
<Tbody>
|
|
476
|
+
{sortedProfiles.map((profile) => (
|
|
477
|
+
<Tr key={profile.id}>
|
|
478
|
+
<Td>
|
|
479
|
+
<Checkbox
|
|
480
|
+
checked={selectedProfiles.includes(profile.id)}
|
|
481
|
+
onCheckedChange={() => handleSelectProfile(profile.id)}
|
|
482
|
+
aria-label={`Select ${profile.name}`}
|
|
483
|
+
/>
|
|
484
|
+
</Td>
|
|
485
|
+
<Td><Typography fontWeight="bold">{profile.name}</Typography></Td>
|
|
486
|
+
<Td><Typography textColor="neutral600">{getContentTypeName(profile.contentType)}</Typography></Td>
|
|
487
|
+
<Td><Badge>{getDirectionLabel(profile.direction)}</Badge></Td>
|
|
488
|
+
<Td><Badge>{profile.conflictStrategy}</Badge></Td>
|
|
489
|
+
<Td>
|
|
490
|
+
<Badge active={!profile.isSimple}>
|
|
491
|
+
{profile.isSimple ? 'Simple' : 'Advanced'}
|
|
492
|
+
</Badge>
|
|
493
|
+
</Td>
|
|
494
|
+
<Td>
|
|
495
|
+
{profile.isActive ? (
|
|
496
|
+
<Badge active>Active</Badge>
|
|
497
|
+
) : (
|
|
498
|
+
<Badge>Inactive</Badge>
|
|
499
|
+
)}
|
|
500
|
+
</Td>
|
|
501
|
+
<Td>
|
|
502
|
+
<Flex gap={1}>
|
|
503
|
+
<IconButton label="Edit" onClick={() => openEditModal(profile)}>
|
|
504
|
+
<Pencil />
|
|
505
|
+
</IconButton>
|
|
506
|
+
<IconButton label="Delete" onClick={() => handleDelete(profile.id)}>
|
|
507
|
+
<Trash />
|
|
508
|
+
</IconButton>
|
|
509
|
+
</Flex>
|
|
510
|
+
</Td>
|
|
511
|
+
</Tr>
|
|
512
|
+
))}
|
|
513
|
+
</Tbody>
|
|
514
|
+
</Table>
|
|
515
|
+
)}
|
|
516
|
+
</Box>
|
|
517
|
+
|
|
518
|
+
{/* Create/Edit Modal */}
|
|
519
|
+
{modalOpen && (
|
|
520
|
+
<Modal.Root open={modalOpen} onOpenChange={setModalOpen}>
|
|
521
|
+
<Modal.Content>
|
|
522
|
+
<Modal.Header>
|
|
523
|
+
<Modal.Title>
|
|
524
|
+
{editingProfile ? 'Edit Sync Profile' : 'Create Sync Profile'}
|
|
525
|
+
</Modal.Title>
|
|
526
|
+
</Modal.Header>
|
|
527
|
+
<Modal.Body>
|
|
528
|
+
{/* Mode Selection (only for new profiles) */}
|
|
529
|
+
{!editingProfile && (
|
|
530
|
+
<Box paddingBottom={4}>
|
|
531
|
+
<Tabs.Root value={createMode} onValueChange={handleModeChange}>
|
|
532
|
+
<Tabs.List>
|
|
533
|
+
<Tabs.Trigger value="simple">Simple</Tabs.Trigger>
|
|
534
|
+
<Tabs.Trigger value="advanced">Advanced</Tabs.Trigger>
|
|
535
|
+
</Tabs.List>
|
|
536
|
+
</Tabs.Root>
|
|
537
|
+
<Box paddingTop={2}>
|
|
538
|
+
<Typography variant="pi" textColor="neutral500">
|
|
539
|
+
{createMode === 'simple'
|
|
540
|
+
? 'Choose a preset and configure basic options.'
|
|
541
|
+
: 'Configure individual field-level sync policies.'}
|
|
542
|
+
</Typography>
|
|
543
|
+
</Box>
|
|
544
|
+
</Box>
|
|
545
|
+
)}
|
|
546
|
+
|
|
547
|
+
{/* Content Type Selection */}
|
|
548
|
+
<Box paddingBottom={4}>
|
|
549
|
+
<Field.Root>
|
|
550
|
+
<Field.Label>Content Type</Field.Label>
|
|
551
|
+
<SingleSelect
|
|
552
|
+
value={formData.contentType}
|
|
553
|
+
onChange={handleContentTypeChange}
|
|
554
|
+
disabled={!!editingProfile}
|
|
555
|
+
>
|
|
556
|
+
<SingleSelectOption value="">Select content type...</SingleSelectOption>
|
|
557
|
+
{getEnabledContentTypes().map((ct) => (
|
|
558
|
+
<SingleSelectOption key={ct.uid} value={ct.uid}>
|
|
559
|
+
{ct.displayName}
|
|
560
|
+
</SingleSelectOption>
|
|
561
|
+
))}
|
|
562
|
+
</SingleSelect>
|
|
563
|
+
<Field.Hint>Only enabled content types are shown</Field.Hint>
|
|
564
|
+
</Field.Root>
|
|
565
|
+
</Box>
|
|
566
|
+
|
|
567
|
+
{/* Simple Mode: Preset Selection */}
|
|
568
|
+
{createMode === 'simple' && !editingProfile && (
|
|
569
|
+
<Box paddingBottom={4}>
|
|
570
|
+
<Typography variant="delta" paddingBottom={2}>Quick Presets</Typography>
|
|
571
|
+
<Flex gap={2} wrap="wrap">
|
|
572
|
+
{SIMPLE_PRESETS.map((preset) => (
|
|
573
|
+
<Button
|
|
574
|
+
key={preset.value}
|
|
575
|
+
variant={selectedPreset === preset.value ? 'default' : 'tertiary'}
|
|
576
|
+
onClick={() => handlePresetSelect(preset.value)}
|
|
577
|
+
size="S"
|
|
578
|
+
>
|
|
579
|
+
{preset.label}
|
|
580
|
+
</Button>
|
|
581
|
+
))}
|
|
582
|
+
</Flex>
|
|
583
|
+
</Box>
|
|
584
|
+
)}
|
|
585
|
+
|
|
586
|
+
{/* Profile Name */}
|
|
587
|
+
<Box paddingBottom={4}>
|
|
588
|
+
<Field.Root>
|
|
589
|
+
<Field.Label>Profile Name</Field.Label>
|
|
590
|
+
<TextInput
|
|
591
|
+
placeholder="e.g., Products - Live Push"
|
|
592
|
+
value={formData.name}
|
|
593
|
+
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
|
594
|
+
/>
|
|
595
|
+
</Field.Root>
|
|
596
|
+
</Box>
|
|
597
|
+
|
|
598
|
+
{/* Direction */}
|
|
599
|
+
<Box paddingBottom={4}>
|
|
600
|
+
<Field.Root>
|
|
601
|
+
<Field.Label>Sync Direction</Field.Label>
|
|
602
|
+
<SingleSelect
|
|
603
|
+
value={formData.direction}
|
|
604
|
+
onChange={(value) => setFormData((p) => ({ ...p, direction: value }))}
|
|
605
|
+
>
|
|
606
|
+
{DIRECTION_OPTIONS.map((opt) => (
|
|
607
|
+
<SingleSelectOption key={opt.value} value={opt.value}>
|
|
608
|
+
{opt.label}
|
|
609
|
+
</SingleSelectOption>
|
|
610
|
+
))}
|
|
611
|
+
</SingleSelect>
|
|
612
|
+
</Field.Root>
|
|
613
|
+
</Box>
|
|
614
|
+
|
|
615
|
+
{/* Conflict Strategy */}
|
|
616
|
+
<Box paddingBottom={4}>
|
|
617
|
+
<Field.Root>
|
|
618
|
+
<Field.Label>Conflict Strategy</Field.Label>
|
|
619
|
+
<SingleSelect
|
|
620
|
+
value={formData.conflictStrategy}
|
|
621
|
+
onChange={(value) => setFormData((p) => ({ ...p, conflictStrategy: value }))}
|
|
622
|
+
>
|
|
623
|
+
{CONFLICT_STRATEGY_OPTIONS.map((opt) => (
|
|
624
|
+
<SingleSelectOption key={opt.value} value={opt.value}>
|
|
625
|
+
{opt.label}
|
|
626
|
+
</SingleSelectOption>
|
|
627
|
+
))}
|
|
628
|
+
</SingleSelect>
|
|
629
|
+
<Field.Hint>How to resolve when the same record is modified on both sides</Field.Hint>
|
|
630
|
+
</Field.Root>
|
|
631
|
+
</Box>
|
|
632
|
+
|
|
633
|
+
{/* Active Checkbox */}
|
|
634
|
+
<Box paddingBottom={4}>
|
|
635
|
+
<Checkbox
|
|
636
|
+
checked={formData.isActive}
|
|
637
|
+
onCheckedChange={(checked) => setFormData((p) => ({ ...p, isActive: checked }))}
|
|
638
|
+
>
|
|
639
|
+
Set as Active Profile
|
|
640
|
+
</Checkbox>
|
|
641
|
+
<Box paddingTop={1}>
|
|
642
|
+
<Typography variant="pi" textColor="neutral500">
|
|
643
|
+
Only one profile can be active per content type.
|
|
644
|
+
</Typography>
|
|
645
|
+
</Box>
|
|
646
|
+
</Box>
|
|
647
|
+
|
|
648
|
+
{/* Advanced Mode: Field Policies */}
|
|
649
|
+
{createMode === 'advanced' && formData.contentType && (
|
|
650
|
+
<Box>
|
|
651
|
+
<Typography variant="delta" paddingBottom={2}>
|
|
652
|
+
Field Policies
|
|
653
|
+
</Typography>
|
|
654
|
+
<Typography variant="pi" textColor="neutral500" paddingBottom={4}>
|
|
655
|
+
Override sync direction for individual fields.
|
|
656
|
+
</Typography>
|
|
657
|
+
|
|
658
|
+
{loadingSchema ? (
|
|
659
|
+
<Typography>Loading fields...</Typography>
|
|
660
|
+
) : schemaFields.length === 0 ? (
|
|
661
|
+
<Typography textColor="neutral500">No fields found</Typography>
|
|
662
|
+
) : (
|
|
663
|
+
<Box background="neutral100" padding={4} hasRadius style={{ maxHeight: '300px', overflow: 'auto' }}>
|
|
664
|
+
{schemaFields.map((field) => (
|
|
665
|
+
<Flex
|
|
666
|
+
key={field.name}
|
|
667
|
+
justifyContent="space-between"
|
|
668
|
+
alignItems="center"
|
|
669
|
+
paddingBottom={2}
|
|
670
|
+
>
|
|
671
|
+
<Box>
|
|
672
|
+
<Typography variant="omega" fontWeight="bold">
|
|
673
|
+
{field.name}
|
|
674
|
+
</Typography>
|
|
675
|
+
<Typography variant="pi" textColor="neutral500">
|
|
676
|
+
{field.type}
|
|
677
|
+
</Typography>
|
|
678
|
+
</Box>
|
|
679
|
+
<Box style={{ minWidth: '140px' }}>
|
|
680
|
+
<SingleSelect
|
|
681
|
+
value={getFieldPolicy(field.name)}
|
|
682
|
+
onChange={(value) => handleFieldPolicyChange(field.name, value)}
|
|
683
|
+
size="S"
|
|
684
|
+
>
|
|
685
|
+
{FIELD_DIRECTION_OPTIONS.map((opt) => (
|
|
686
|
+
<SingleSelectOption key={opt.value} value={opt.value}>
|
|
687
|
+
{opt.label}
|
|
688
|
+
</SingleSelectOption>
|
|
689
|
+
))}
|
|
690
|
+
</SingleSelect>
|
|
691
|
+
</Box>
|
|
692
|
+
</Flex>
|
|
693
|
+
))}
|
|
694
|
+
</Box>
|
|
695
|
+
)}
|
|
696
|
+
</Box>
|
|
697
|
+
)}
|
|
698
|
+
</Modal.Body>
|
|
699
|
+
<Modal.Footer>
|
|
700
|
+
<Modal.Close>
|
|
701
|
+
<Button variant="tertiary">Cancel</Button>
|
|
702
|
+
</Modal.Close>
|
|
703
|
+
<Button onClick={handleSave} disabled={!formData.name || !formData.contentType}>
|
|
704
|
+
{editingProfile ? 'Update Profile' : 'Create Profile'}
|
|
705
|
+
</Button>
|
|
706
|
+
</Modal.Footer>
|
|
707
|
+
</Modal.Content>
|
|
708
|
+
</Modal.Root>
|
|
709
|
+
)}
|
|
710
|
+
</Box>
|
|
711
|
+
);
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
export { SyncProfilesTab };
|
|
715
|
+
export default SyncProfilesTab;
|