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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/admin/src/components/ConfigTab.jsx +1038 -0
  4. package/admin/src/components/ContentTypesTab.jsx +160 -0
  5. package/admin/src/components/HelpTab.jsx +945 -0
  6. package/admin/src/components/LogsTab.jsx +136 -0
  7. package/admin/src/components/MediaTab.jsx +557 -0
  8. package/admin/src/components/SyncProfilesTab.jsx +715 -0
  9. package/admin/src/components/SyncTab.jsx +988 -0
  10. package/admin/src/index.js +31 -0
  11. package/admin/src/pages/App/index.jsx +129 -0
  12. package/admin/src/pluginId.js +3 -0
  13. package/package.json +84 -0
  14. package/server/src/bootstrap.js +151 -0
  15. package/server/src/config/index.js +5 -0
  16. package/server/src/content-types/index.js +7 -0
  17. package/server/src/content-types/sync-log/schema.json +24 -0
  18. package/server/src/controllers/alerts.js +59 -0
  19. package/server/src/controllers/config.js +292 -0
  20. package/server/src/controllers/content-type-discovery.js +9 -0
  21. package/server/src/controllers/dependencies.js +109 -0
  22. package/server/src/controllers/index.js +29 -0
  23. package/server/src/controllers/ping.js +7 -0
  24. package/server/src/controllers/sync-config.js +26 -0
  25. package/server/src/controllers/sync-enforcement.js +323 -0
  26. package/server/src/controllers/sync-execution.js +134 -0
  27. package/server/src/controllers/sync-log.js +18 -0
  28. package/server/src/controllers/sync-media.js +158 -0
  29. package/server/src/controllers/sync-profiles.js +182 -0
  30. package/server/src/controllers/sync.js +31 -0
  31. package/server/src/destroy.js +7 -0
  32. package/server/src/index.js +21 -0
  33. package/server/src/middlewares/verify-signature.js +32 -0
  34. package/server/src/register.js +7 -0
  35. package/server/src/routes/index.js +111 -0
  36. package/server/src/services/alerts.js +437 -0
  37. package/server/src/services/config.js +68 -0
  38. package/server/src/services/content-type-discovery.js +41 -0
  39. package/server/src/services/dependency-resolver.js +284 -0
  40. package/server/src/services/index.js +30 -0
  41. package/server/src/services/ping.js +7 -0
  42. package/server/src/services/sync-config.js +45 -0
  43. package/server/src/services/sync-enforcement.js +362 -0
  44. package/server/src/services/sync-execution.js +541 -0
  45. package/server/src/services/sync-log.js +56 -0
  46. package/server/src/services/sync-media.js +963 -0
  47. package/server/src/services/sync-profiles.js +380 -0
  48. package/server/src/services/sync.js +248 -0
  49. package/server/src/utils/applier.js +89 -0
  50. package/server/src/utils/comparator.js +83 -0
  51. package/server/src/utils/fetcher.js +142 -0
  52. package/server/src/utils/hmac.js +37 -0
  53. package/server/src/utils/pagination.js +51 -0
  54. package/server/src/utils/sync-guard.js +29 -0
  55. 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;