configuration-management 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +199 -0
- package/actions/configurations/commerce/index.js +55 -0
- package/actions/configurations/export-config/index.js +259 -0
- package/actions/configurations/ext.config.yaml +151 -0
- package/actions/configurations/import-config/index.js +544 -0
- package/actions/configurations/registration/index.js +37 -0
- package/actions/configurations/sync-store-mappings-from-commerce/index.js +199 -0
- package/actions/configurations/system-config-list/index.js +127 -0
- package/actions/configurations/system-config-save/index.js +160 -0
- package/actions/configurations/system-config-schema/index.js +327 -0
- package/actions/utils.js +73 -0
- package/package.json +74 -0
- package/scripts/setup-app-config.js +114 -0
- package/src/abdb-config.js +241 -0
- package/src/abdb-helper.js +476 -0
- package/src/index.js +20 -0
- package/src/oauth1a.js +135 -0
- package/src/system-config-crypto.js +113 -0
- package/src/system-config-shared.js +89 -0
- package/web/src/components/App.js +47 -0
- package/web/src/components/AppSectionNav.js +49 -0
- package/web/src/components/ExtensionRegistration.js +33 -0
- package/web/src/components/MainPage.js +46 -0
- package/web/src/components/SystemConfig.js +1464 -0
- package/web/src/components/SystemConfigSchemaEditor.js +459 -0
- package/web/src/hooks/useConfirm.js +355 -0
- package/web/src/hooks/useSystemConfig.js +238 -0
- package/web/src/hooks/useSystemConfigSchema.js +102 -0
- package/web/src/index.js +41 -0
- package/web/src/schema/systemConfigSchema.js +82 -0
- package/web/src/settings.js +57 -0
- package/web/src/styles/index.css +326 -0
- package/web/src/theme.js +104 -0
- package/web/src/utils/storeMappingsFromCommerceRest.js +73 -0
- package/web/src/utils.js +52 -0
|
@@ -0,0 +1,1464 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2025 Adobe. All rights reserved.
|
|
3
|
+
This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useMemo, useEffect, useRef } from 'react'
|
|
9
|
+
import { Link } from 'react-router-dom'
|
|
10
|
+
import {
|
|
11
|
+
View,
|
|
12
|
+
Flex,
|
|
13
|
+
Heading,
|
|
14
|
+
Text,
|
|
15
|
+
Button,
|
|
16
|
+
ActionButton,
|
|
17
|
+
TooltipTrigger,
|
|
18
|
+
Tooltip,
|
|
19
|
+
TextField,
|
|
20
|
+
TextArea,
|
|
21
|
+
NumberField,
|
|
22
|
+
Switch,
|
|
23
|
+
Checkbox,
|
|
24
|
+
Picker,
|
|
25
|
+
Item,
|
|
26
|
+
Section,
|
|
27
|
+
ProgressCircle,
|
|
28
|
+
ProgressBar,
|
|
29
|
+
Divider,
|
|
30
|
+
Well
|
|
31
|
+
} from '@adobe/react-spectrum'
|
|
32
|
+
import Settings from '@spectrum-icons/workflow/Settings'
|
|
33
|
+
import Globe from '@spectrum-icons/workflow/Globe'
|
|
34
|
+
import Refresh from '@spectrum-icons/workflow/Refresh'
|
|
35
|
+
import Edit from '@spectrum-icons/workflow/Edit'
|
|
36
|
+
import CloudUpload from '@spectrum-icons/workflow/UploadToCloud'
|
|
37
|
+
import LockClosed from '@spectrum-icons/workflow/LockClosed'
|
|
38
|
+
import Back from '@spectrum-icons/workflow/Back'
|
|
39
|
+
import ChevronDown from '@spectrum-icons/workflow/ChevronDown'
|
|
40
|
+
import ChevronRight from '@spectrum-icons/workflow/ChevronRight'
|
|
41
|
+
import { useSystemConfig } from '../hooks/useSystemConfig'
|
|
42
|
+
import { useSystemConfigSchema } from '../hooks/useSystemConfigSchema'
|
|
43
|
+
import { useConfirm } from '../hooks/useConfirm'
|
|
44
|
+
import { isFieldVisibleAtScope, coerceDefault } from '../schema/systemConfigSchema'
|
|
45
|
+
import SystemConfigSchemaEditor from './SystemConfigSchemaEditor'
|
|
46
|
+
import { callAction } from '../utils'
|
|
47
|
+
import { getActionKey } from '../settings'
|
|
48
|
+
import { PALETTE, RADIUS, SHADOW } from '../theme'
|
|
49
|
+
|
|
50
|
+
// ----------------------------------------------------------------------------
|
|
51
|
+
// Design tokens
|
|
52
|
+
// ----------------------------------------------------------------------------
|
|
53
|
+
// Height (px) reserved by the AppSectionNav strip at the top of every page.
|
|
54
|
+
// Sticky elements inside SystemConfig stack vertically below the section nav
|
|
55
|
+
// + the hero card. The hero height is measured at runtime (subtitle wrap
|
|
56
|
+
// changes its height) and exposed via the `--sc-hero-h` CSS variable so the
|
|
57
|
+
// save bar / sidebar always sit flush against the bottom of the hero.
|
|
58
|
+
const APP_NAV_OFFSET = 64
|
|
59
|
+
const HERO_HEIGHT = 160 // initial estimate; overridden via CSS var once measured
|
|
60
|
+
const SAVE_BAR_HEIGHT = 64 // save bar (action row + padding)
|
|
61
|
+
const HERO_VAR = `var(--sc-hero-h, ${HERO_HEIGHT}px)`
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
// ----------------------------------------------------------------------------
|
|
65
|
+
// Scope tree (Magento-style hierarchical Picker)
|
|
66
|
+
// ----------------------------------------------------------------------------
|
|
67
|
+
function buildScopeTreeForPicker (scopeTree) {
|
|
68
|
+
const def = { key: 'default::0', label: 'Default Config', scope: 'default', scopeId: '0' }
|
|
69
|
+
const websites = []
|
|
70
|
+
const all = [def]
|
|
71
|
+
const groupsById = new Map((scopeTree.storeGroups || []).map((g) => [String(g.id), g]))
|
|
72
|
+
|
|
73
|
+
for (const w of scopeTree.websites) {
|
|
74
|
+
const websiteOption = {
|
|
75
|
+
key: `websites::${w.id}`,
|
|
76
|
+
label: w.name || w.code || `Website ${w.id}`,
|
|
77
|
+
scope: 'websites',
|
|
78
|
+
scopeId: String(w.id)
|
|
79
|
+
}
|
|
80
|
+
all.push(websiteOption)
|
|
81
|
+
|
|
82
|
+
const storesForWebsite = (scopeTree.stores || []).filter(
|
|
83
|
+
(s) => String(s.website_id) === String(w.id)
|
|
84
|
+
)
|
|
85
|
+
storesForWebsite.sort((a, b) => {
|
|
86
|
+
const ga = groupsById.get(String(a.store_group_id))?.name || ''
|
|
87
|
+
const gb = groupsById.get(String(b.store_group_id))?.name || ''
|
|
88
|
+
if (ga !== gb) return ga.localeCompare(gb)
|
|
89
|
+
return (a.name || '').localeCompare(b.name || '')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const items = storesForWebsite.map((s) => {
|
|
93
|
+
const groupName = groupsById.get(String(s.store_group_id))?.name || ''
|
|
94
|
+
const label = groupName ? `${groupName} / ${s.name}` : s.name
|
|
95
|
+
const option = { key: `stores::${s.id}`, label, scope: 'stores', scopeId: String(s.id) }
|
|
96
|
+
all.push(option)
|
|
97
|
+
return option
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
websites.push({
|
|
101
|
+
websiteId: String(w.id),
|
|
102
|
+
websiteName: websiteOption.label,
|
|
103
|
+
websiteOption,
|
|
104
|
+
items
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { all, default: def, websites }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ----------------------------------------------------------------------------
|
|
112
|
+
// Small UI atoms
|
|
113
|
+
// ----------------------------------------------------------------------------
|
|
114
|
+
function Pill ({ children, tone = 'neutral' }) {
|
|
115
|
+
const tones = {
|
|
116
|
+
neutral: { bg: PALETTE.neutralSoft, fg: PALETTE.neutralText },
|
|
117
|
+
accent: { bg: PALETTE.accentSoft, fg: PALETTE.accent },
|
|
118
|
+
warning: { bg: PALETTE.warningSoft, fg: PALETTE.warning },
|
|
119
|
+
success: { bg: PALETTE.successSoft, fg: PALETTE.success },
|
|
120
|
+
danger: { bg: PALETTE.dangerSoft, fg: PALETTE.danger }
|
|
121
|
+
}
|
|
122
|
+
const t = tones[tone] || tones.neutral
|
|
123
|
+
return (
|
|
124
|
+
<span
|
|
125
|
+
style={{
|
|
126
|
+
display: 'inline-flex',
|
|
127
|
+
alignItems: 'center',
|
|
128
|
+
gap: 4,
|
|
129
|
+
padding: '2px 8px',
|
|
130
|
+
borderRadius: RADIUS.pill,
|
|
131
|
+
background: t.bg,
|
|
132
|
+
color: t.fg,
|
|
133
|
+
fontSize: 11,
|
|
134
|
+
fontWeight: 600,
|
|
135
|
+
lineHeight: '16px',
|
|
136
|
+
letterSpacing: 0.2,
|
|
137
|
+
whiteSpace: 'nowrap'
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
{children}
|
|
141
|
+
</span>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function Card ({ children, padded = true, style = {} }) {
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
style={{
|
|
149
|
+
background: PALETTE.surface,
|
|
150
|
+
border: `1px solid ${PALETTE.border}`,
|
|
151
|
+
borderRadius: RADIUS.lg,
|
|
152
|
+
boxShadow: SHADOW.xs,
|
|
153
|
+
...(padded ? { padding: 20 } : {}),
|
|
154
|
+
...style
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
{children}
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function SectionDivider ({ label }) {
|
|
163
|
+
return (
|
|
164
|
+
<div style={{
|
|
165
|
+
fontSize: 11,
|
|
166
|
+
fontWeight: 700,
|
|
167
|
+
letterSpacing: 0.8,
|
|
168
|
+
textTransform: 'uppercase',
|
|
169
|
+
color: PALETTE.textMuted,
|
|
170
|
+
padding: '14px 12px 6px'
|
|
171
|
+
}}>{label}</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ----------------------------------------------------------------------------
|
|
176
|
+
// Field renderer
|
|
177
|
+
// ----------------------------------------------------------------------------
|
|
178
|
+
function FieldControl ({ field, value, disabled, sensitivePlaceholder, onChange }) {
|
|
179
|
+
const isMasked = value === sensitivePlaceholder
|
|
180
|
+
|
|
181
|
+
switch (field.type) {
|
|
182
|
+
case 'textarea':
|
|
183
|
+
return (
|
|
184
|
+
<View width="size-4600">
|
|
185
|
+
<TextArea
|
|
186
|
+
aria-label={field.label}
|
|
187
|
+
value={value ?? ''}
|
|
188
|
+
isDisabled={disabled}
|
|
189
|
+
onChange={onChange}
|
|
190
|
+
width="100%"
|
|
191
|
+
UNSAFE_className="sm-textarea"
|
|
192
|
+
/>
|
|
193
|
+
</View>
|
|
194
|
+
)
|
|
195
|
+
case 'password':
|
|
196
|
+
return (
|
|
197
|
+
<TextField
|
|
198
|
+
aria-label={field.label}
|
|
199
|
+
type="password"
|
|
200
|
+
value={isMasked ? '' : (value ?? '')}
|
|
201
|
+
isDisabled={disabled}
|
|
202
|
+
onChange={onChange}
|
|
203
|
+
placeholder={isMasked ? '••••• (encrypted, leave blank to keep)' : ''}
|
|
204
|
+
width="size-4600"
|
|
205
|
+
/>
|
|
206
|
+
)
|
|
207
|
+
case 'number':
|
|
208
|
+
return (
|
|
209
|
+
<NumberField
|
|
210
|
+
aria-label={field.label}
|
|
211
|
+
value={typeof value === 'number' ? value : Number(value) || 0}
|
|
212
|
+
isDisabled={disabled}
|
|
213
|
+
onChange={onChange}
|
|
214
|
+
width="size-3000"
|
|
215
|
+
/>
|
|
216
|
+
)
|
|
217
|
+
case 'boolean':
|
|
218
|
+
return (
|
|
219
|
+
<Switch isSelected={!!value} isDisabled={disabled} onChange={onChange}>
|
|
220
|
+
{value ? 'Yes' : 'No'}
|
|
221
|
+
</Switch>
|
|
222
|
+
)
|
|
223
|
+
case 'select':
|
|
224
|
+
return (
|
|
225
|
+
<Picker
|
|
226
|
+
aria-label={field.label}
|
|
227
|
+
selectedKey={value ?? field.default}
|
|
228
|
+
isDisabled={disabled}
|
|
229
|
+
onSelectionChange={onChange}
|
|
230
|
+
width="size-3600"
|
|
231
|
+
>
|
|
232
|
+
{(field.options || []).map((opt) => (
|
|
233
|
+
<Item key={opt.value}>{opt.label}</Item>
|
|
234
|
+
))}
|
|
235
|
+
</Picker>
|
|
236
|
+
)
|
|
237
|
+
case 'text':
|
|
238
|
+
default:
|
|
239
|
+
return (
|
|
240
|
+
<TextField
|
|
241
|
+
aria-label={field.label}
|
|
242
|
+
value={value ?? ''}
|
|
243
|
+
isDisabled={disabled}
|
|
244
|
+
onChange={onChange}
|
|
245
|
+
width="size-4600"
|
|
246
|
+
/>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function FieldRow ({
|
|
252
|
+
field,
|
|
253
|
+
path,
|
|
254
|
+
scope,
|
|
255
|
+
displayValue,
|
|
256
|
+
origin,
|
|
257
|
+
inherited,
|
|
258
|
+
onFieldChange,
|
|
259
|
+
onUseDefaultChange,
|
|
260
|
+
sensitivePlaceholder
|
|
261
|
+
}) {
|
|
262
|
+
const allowed = isFieldVisibleAtScope(field, scope.scope)
|
|
263
|
+
const showUseDefault = scope.scope !== 'default' && allowed
|
|
264
|
+
const editorDisabled = !allowed || (showUseDefault && inherited)
|
|
265
|
+
const isTextarea = field.type === 'textarea'
|
|
266
|
+
|
|
267
|
+
const originLabel = origin
|
|
268
|
+
? origin.scope === 'default' ? 'inherited from Default Config' : `set at ${origin.scope}:${origin.scopeId}`
|
|
269
|
+
: 'unset'
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<div
|
|
273
|
+
style={{
|
|
274
|
+
display: 'grid',
|
|
275
|
+
gridTemplateColumns: '220px 1fr auto',
|
|
276
|
+
gap: 16,
|
|
277
|
+
alignItems: isTextarea ? 'start' : 'center',
|
|
278
|
+
padding: '14px 0',
|
|
279
|
+
borderBottom: `1px solid ${PALETTE.border}`
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
<div style={{ paddingTop: isTextarea ? 6 : 0 }}>
|
|
283
|
+
<div style={{
|
|
284
|
+
fontSize: 13,
|
|
285
|
+
fontWeight: 600,
|
|
286
|
+
color: PALETTE.text,
|
|
287
|
+
display: 'flex',
|
|
288
|
+
alignItems: 'center',
|
|
289
|
+
gap: 6
|
|
290
|
+
}}>
|
|
291
|
+
{field.label}
|
|
292
|
+
{field.sensitive && (
|
|
293
|
+
<TooltipTrigger>
|
|
294
|
+
<span style={{ display: 'inline-flex', color: PALETTE.textMuted }}>
|
|
295
|
+
<LockClosed size="XS" />
|
|
296
|
+
</span>
|
|
297
|
+
<Tooltip>Encrypted at rest</Tooltip>
|
|
298
|
+
</TooltipTrigger>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
<div style={{ marginTop: 4, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
|
302
|
+
{!allowed && <Pill tone="warning">Not configurable here</Pill>}
|
|
303
|
+
{allowed && scope.scope !== 'default' && (
|
|
304
|
+
<Pill tone={inherited ? 'neutral' : 'accent'}>
|
|
305
|
+
{inherited ? originLabel : 'overridden'}
|
|
306
|
+
</Pill>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<div>
|
|
312
|
+
<FieldControl
|
|
313
|
+
field={field}
|
|
314
|
+
value={displayValue}
|
|
315
|
+
disabled={editorDisabled}
|
|
316
|
+
sensitivePlaceholder={sensitivePlaceholder}
|
|
317
|
+
onChange={(v) => onFieldChange(path, v)}
|
|
318
|
+
/>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<div>
|
|
322
|
+
{showUseDefault && (
|
|
323
|
+
<Checkbox
|
|
324
|
+
isSelected={inherited}
|
|
325
|
+
onChange={(checked) => onUseDefaultChange(path, checked)}
|
|
326
|
+
>
|
|
327
|
+
Use Default
|
|
328
|
+
</Checkbox>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ----------------------------------------------------------------------------
|
|
336
|
+
// Group card (collapsible)
|
|
337
|
+
// ----------------------------------------------------------------------------
|
|
338
|
+
function GroupCard ({
|
|
339
|
+
group,
|
|
340
|
+
sectionId,
|
|
341
|
+
scope,
|
|
342
|
+
collapsed,
|
|
343
|
+
onToggle,
|
|
344
|
+
getDisplayValue,
|
|
345
|
+
getOrigin,
|
|
346
|
+
isInheritedAtScope,
|
|
347
|
+
setFieldValue,
|
|
348
|
+
setUseDefault,
|
|
349
|
+
sensitivePlaceholder
|
|
350
|
+
}) {
|
|
351
|
+
return (
|
|
352
|
+
<Card padded={false} style={{ marginBottom: 16 }}>
|
|
353
|
+
<button
|
|
354
|
+
type="button"
|
|
355
|
+
onClick={onToggle}
|
|
356
|
+
aria-expanded={!collapsed}
|
|
357
|
+
style={{
|
|
358
|
+
display: 'flex',
|
|
359
|
+
alignItems: 'center',
|
|
360
|
+
justifyContent: 'space-between',
|
|
361
|
+
width: '100%',
|
|
362
|
+
padding: '14px 20px',
|
|
363
|
+
background: 'transparent',
|
|
364
|
+
border: 0,
|
|
365
|
+
borderBottom: collapsed ? 0 : `1px solid ${PALETTE.border}`,
|
|
366
|
+
cursor: 'pointer',
|
|
367
|
+
userSelect: 'none',
|
|
368
|
+
font: 'inherit',
|
|
369
|
+
color: 'inherit',
|
|
370
|
+
textAlign: 'left'
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
374
|
+
<span style={{ color: PALETTE.textMuted, display: 'inline-flex' }}>
|
|
375
|
+
{collapsed ? <ChevronRight size="S" /> : <ChevronDown size="S" />}
|
|
376
|
+
</span>
|
|
377
|
+
<span style={{ fontWeight: 700, fontSize: 15, color: PALETTE.text }}>{group.label}</span>
|
|
378
|
+
<Pill tone="neutral">{(group.fields || []).length} fields</Pill>
|
|
379
|
+
</div>
|
|
380
|
+
</button>
|
|
381
|
+
{!collapsed && (
|
|
382
|
+
<div style={{ padding: '4px 20px 16px' }}>
|
|
383
|
+
{(group.fields || []).map((field) => {
|
|
384
|
+
const path = `${sectionId}/${group.id}/${field.id}`
|
|
385
|
+
const inherited = isInheritedAtScope(path)
|
|
386
|
+
const displayValue = getDisplayValue(path, coerceDefault(field))
|
|
387
|
+
return (
|
|
388
|
+
<FieldRow
|
|
389
|
+
key={path}
|
|
390
|
+
field={field}
|
|
391
|
+
path={path}
|
|
392
|
+
scope={scope}
|
|
393
|
+
displayValue={displayValue}
|
|
394
|
+
origin={getOrigin(path)}
|
|
395
|
+
inherited={inherited}
|
|
396
|
+
onFieldChange={setFieldValue}
|
|
397
|
+
onUseDefaultChange={setUseDefault}
|
|
398
|
+
sensitivePlaceholder={sensitivePlaceholder}
|
|
399
|
+
/>
|
|
400
|
+
)
|
|
401
|
+
})}
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
</Card>
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ----------------------------------------------------------------------------
|
|
409
|
+
// Sidebar (sections)
|
|
410
|
+
// ----------------------------------------------------------------------------
|
|
411
|
+
function Sidebar ({ sections, activeSectionId, onSelect }) {
|
|
412
|
+
return (
|
|
413
|
+
<aside
|
|
414
|
+
role="tablist"
|
|
415
|
+
aria-label="Sections"
|
|
416
|
+
style={{
|
|
417
|
+
width: 260,
|
|
418
|
+
flexShrink: 0,
|
|
419
|
+
// Pill-track styling that matches the top AppSectionNav: muted grey
|
|
420
|
+
// track with inset shadow, full-rounded radius, holding individual
|
|
421
|
+
// rounded pill buttons.
|
|
422
|
+
background: PALETTE.surfaceMuted,
|
|
423
|
+
border: `1px solid ${PALETTE.border}`,
|
|
424
|
+
borderRadius: RADIUS.xxl,
|
|
425
|
+
boxShadow: SHADOW.inset,
|
|
426
|
+
padding: 6,
|
|
427
|
+
position: 'sticky',
|
|
428
|
+
// Sit below the hero + save bar (which are also sticky) so the
|
|
429
|
+
// sidebar never overlaps either of them. Uses the runtime-measured
|
|
430
|
+
// hero height so the offset stays correct on viewport resize.
|
|
431
|
+
top: `calc(${APP_NAV_OFFSET}px + ${HERO_VAR} + ${SAVE_BAR_HEIGHT + 16}px)`,
|
|
432
|
+
alignSelf: 'flex-start',
|
|
433
|
+
maxHeight: `calc(100vh - ${APP_NAV_OFFSET}px - ${HERO_VAR} - ${SAVE_BAR_HEIGHT + 32}px)`,
|
|
434
|
+
overflowY: 'auto',
|
|
435
|
+
zIndex: 5,
|
|
436
|
+
display: 'flex',
|
|
437
|
+
flexDirection: 'column',
|
|
438
|
+
gap: 4
|
|
439
|
+
}}
|
|
440
|
+
>
|
|
441
|
+
<div style={{
|
|
442
|
+
padding: '6px 14px 4px',
|
|
443
|
+
fontSize: 10,
|
|
444
|
+
fontWeight: 700,
|
|
445
|
+
letterSpacing: 0.8,
|
|
446
|
+
textTransform: 'uppercase',
|
|
447
|
+
color: PALETTE.textMuted
|
|
448
|
+
}}>
|
|
449
|
+
Sections
|
|
450
|
+
</div>
|
|
451
|
+
{sections.map((section) => {
|
|
452
|
+
const active = section.id === activeSectionId
|
|
453
|
+
const fieldCount = (section.groups || []).reduce((n, g) => n + (g.fields || []).length, 0)
|
|
454
|
+
return (
|
|
455
|
+
<button
|
|
456
|
+
key={section.id}
|
|
457
|
+
type="button"
|
|
458
|
+
role="tab"
|
|
459
|
+
aria-selected={active}
|
|
460
|
+
onClick={() => onSelect(section.id)}
|
|
461
|
+
style={{
|
|
462
|
+
display: 'flex',
|
|
463
|
+
alignItems: 'center',
|
|
464
|
+
gap: 10,
|
|
465
|
+
width: '100%',
|
|
466
|
+
padding: '10px 14px',
|
|
467
|
+
border: 0,
|
|
468
|
+
borderRadius: RADIUS.pill,
|
|
469
|
+
background: active ? PALETTE.surface : 'transparent',
|
|
470
|
+
cursor: active ? 'default' : 'pointer',
|
|
471
|
+
font: 'inherit',
|
|
472
|
+
color: active ? PALETTE.accent : PALETTE.neutralText,
|
|
473
|
+
fontWeight: active ? 700 : 600,
|
|
474
|
+
fontSize: 13,
|
|
475
|
+
textAlign: 'left',
|
|
476
|
+
boxShadow: active ? SHADOW.pill : 'none',
|
|
477
|
+
transition: 'background 140ms ease, color 140ms ease, box-shadow 140ms ease'
|
|
478
|
+
}}
|
|
479
|
+
onMouseOver={(e) => { if (!active) { e.currentTarget.style.background = PALETTE.surface; e.currentTarget.style.color = PALETTE.text } }}
|
|
480
|
+
onMouseOut={(e) => { if (!active) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = PALETTE.neutralText } }}
|
|
481
|
+
>
|
|
482
|
+
<span style={{ display: 'inline-flex', opacity: active ? 1 : 0.7 }}>
|
|
483
|
+
<Settings size="XS" />
|
|
484
|
+
</span>
|
|
485
|
+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
486
|
+
{section.label}
|
|
487
|
+
</span>
|
|
488
|
+
<Pill tone={active ? 'accent' : 'neutral'}>{fieldCount}</Pill>
|
|
489
|
+
</button>
|
|
490
|
+
)
|
|
491
|
+
})}
|
|
492
|
+
</aside>
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ----------------------------------------------------------------------------
|
|
497
|
+
// Values view
|
|
498
|
+
// ----------------------------------------------------------------------------
|
|
499
|
+
function ValuesView ({ schema, onEditSchema, toolsOpen, setToolsOpen, configCtx }) {
|
|
500
|
+
const {
|
|
501
|
+
scope,
|
|
502
|
+
scopeTree,
|
|
503
|
+
getDisplayValue,
|
|
504
|
+
getOrigin,
|
|
505
|
+
isInheritedAtScope,
|
|
506
|
+
setFieldValue,
|
|
507
|
+
setUseDefault,
|
|
508
|
+
dirtyCount,
|
|
509
|
+
loading,
|
|
510
|
+
saving,
|
|
511
|
+
error,
|
|
512
|
+
savedAt,
|
|
513
|
+
save,
|
|
514
|
+
reset,
|
|
515
|
+
refresh,
|
|
516
|
+
SENSITIVE_PLACEHOLDER
|
|
517
|
+
} = configCtx
|
|
518
|
+
|
|
519
|
+
const sections = schema?.sections || []
|
|
520
|
+
const [activeSectionId, setActiveSectionId] = useState(sections[0]?.id)
|
|
521
|
+
const activeSection = useMemo(() => {
|
|
522
|
+
if (sections.length === 0) return null
|
|
523
|
+
return sections.find((s) => s.id === activeSectionId) || sections[0]
|
|
524
|
+
}, [sections, activeSectionId])
|
|
525
|
+
|
|
526
|
+
const scopeTreeForPicker = useMemo(() => buildScopeTreeForPicker(scopeTree), [scopeTree])
|
|
527
|
+
const scopeKey = `${scope.scope}::${scope.scopeId}`
|
|
528
|
+
const activeScopeLabel = scopeTreeForPicker.all.find((o) => o.key === scopeKey)?.label || 'Default Config'
|
|
529
|
+
|
|
530
|
+
const [collapsedGroups, setCollapsedGroups] = useState({})
|
|
531
|
+
useEffect(() => { setCollapsedGroups({}) }, [activeSection?.id])
|
|
532
|
+
const toggleGroup = (gid) => setCollapsedGroups((prev) => ({ ...prev, [gid]: !prev[gid] }))
|
|
533
|
+
const setAllGroups = (collapsed) => {
|
|
534
|
+
const next = {}
|
|
535
|
+
for (const g of activeSection?.groups || []) next[g.id] = collapsed
|
|
536
|
+
setCollapsedGroups(next)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (sections.length === 0) {
|
|
540
|
+
return (
|
|
541
|
+
<Card>
|
|
542
|
+
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
|
|
543
|
+
<div style={{
|
|
544
|
+
display: 'inline-flex',
|
|
545
|
+
padding: 16,
|
|
546
|
+
background: PALETTE.accentSoft,
|
|
547
|
+
borderRadius: '50%',
|
|
548
|
+
marginBottom: 12,
|
|
549
|
+
color: PALETTE.accent
|
|
550
|
+
}}>
|
|
551
|
+
<Settings size="L" />
|
|
552
|
+
</div>
|
|
553
|
+
<Heading level={3} marginTop={0}>No configuration schema yet</Heading>
|
|
554
|
+
<Text UNSAFE_style={{ color: PALETTE.textMuted, maxWidth: 460, display: 'inline-block' }}>
|
|
555
|
+
Open the Schema Designer to define sections, groups, and fields for your sync integrations.
|
|
556
|
+
</Text>
|
|
557
|
+
<Flex justifyContent="center" gap="size-150" marginTop="size-200">
|
|
558
|
+
<Button variant="cta" onPress={onEditSchema}>Open Schema Designer</Button>
|
|
559
|
+
</Flex>
|
|
560
|
+
</div>
|
|
561
|
+
</Card>
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return (
|
|
566
|
+
<>
|
|
567
|
+
{error && (
|
|
568
|
+
<Well marginBottom="size-200" UNSAFE_style={{ borderColor: PALETTE.danger }}>
|
|
569
|
+
<Text UNSAFE_style={{ color: PALETTE.danger }}>{error}</Text>
|
|
570
|
+
</Well>
|
|
571
|
+
)}
|
|
572
|
+
|
|
573
|
+
{/* Save bar — sticks to the top of the page (just under the hero card)
|
|
574
|
+
so the primary CTA is always in view as the user scrolls long forms. */}
|
|
575
|
+
<div
|
|
576
|
+
style={{
|
|
577
|
+
position: 'sticky',
|
|
578
|
+
// Hero card sticks at APP_NAV_OFFSET; this save bar sits flush
|
|
579
|
+
// against the hero's bottom edge (measured at runtime via
|
|
580
|
+
// --sc-hero-h so the gap is always zero regardless of subtitle
|
|
581
|
+
// wrap).
|
|
582
|
+
top: `calc(${APP_NAV_OFFSET}px + ${HERO_VAR})`,
|
|
583
|
+
marginBottom: 16,
|
|
584
|
+
padding: '12px 20px',
|
|
585
|
+
background: PALETTE.surface,
|
|
586
|
+
border: `1px solid ${PALETTE.border}`,
|
|
587
|
+
borderRadius: RADIUS.xl,
|
|
588
|
+
boxShadow: SHADOW.floating,
|
|
589
|
+
zIndex: 10
|
|
590
|
+
}}
|
|
591
|
+
>
|
|
592
|
+
<Flex gap="size-150" alignItems="center" justifyContent="space-between">
|
|
593
|
+
<div style={{ fontSize: 12, color: PALETTE.textMuted }}>
|
|
594
|
+
{dirtyCount > 0
|
|
595
|
+
? <span style={{ color: PALETTE.warning, fontWeight: 600 }}>{dirtyCount} unsaved change{dirtyCount === 1 ? '' : 's'}</span>
|
|
596
|
+
: savedAt && !saving
|
|
597
|
+
? <span style={{ color: PALETTE.success, fontWeight: 600 }}>✓ Saved {new Date(savedAt).toLocaleTimeString()}</span>
|
|
598
|
+
: 'All changes saved'}
|
|
599
|
+
</div>
|
|
600
|
+
<Flex gap="size-100" alignItems="center">
|
|
601
|
+
<Button variant="secondary" onPress={refresh} isDisabled={saving || loading}>
|
|
602
|
+
Reload
|
|
603
|
+
</Button>
|
|
604
|
+
<Button variant="secondary" onPress={reset} isDisabled={saving || dirtyCount === 0}>
|
|
605
|
+
Reset
|
|
606
|
+
</Button>
|
|
607
|
+
<Button variant="cta" onPress={save} isDisabled={saving || loading || dirtyCount === 0}>
|
|
608
|
+
{saving ? 'Saving…' : `Save Config${dirtyCount ? ` (${dirtyCount})` : ''}`}
|
|
609
|
+
</Button>
|
|
610
|
+
</Flex>
|
|
611
|
+
</Flex>
|
|
612
|
+
</div>
|
|
613
|
+
|
|
614
|
+
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
|
615
|
+
<Sidebar
|
|
616
|
+
sections={sections}
|
|
617
|
+
activeSectionId={activeSection?.id}
|
|
618
|
+
onSelect={setActiveSectionId}
|
|
619
|
+
/>
|
|
620
|
+
|
|
621
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
622
|
+
<div style={{
|
|
623
|
+
display: 'flex',
|
|
624
|
+
justifyContent: 'space-between',
|
|
625
|
+
alignItems: 'center',
|
|
626
|
+
marginBottom: 16
|
|
627
|
+
}}>
|
|
628
|
+
<div>
|
|
629
|
+
<div style={{ fontSize: 12, color: PALETTE.textMuted, fontWeight: 600, marginBottom: 4 }}>
|
|
630
|
+
{activeScopeLabel}
|
|
631
|
+
</div>
|
|
632
|
+
<Heading level={2} marginTop={0} marginBottom={0}>{activeSection?.label}</Heading>
|
|
633
|
+
</div>
|
|
634
|
+
{(activeSection?.groups || []).length > 1 && (
|
|
635
|
+
<Flex gap="size-50">
|
|
636
|
+
<ActionButton onPress={() => setAllGroups(false)} isQuiet>Expand all</ActionButton>
|
|
637
|
+
<ActionButton onPress={() => setAllGroups(true)} isQuiet>Collapse all</ActionButton>
|
|
638
|
+
</Flex>
|
|
639
|
+
)}
|
|
640
|
+
</div>
|
|
641
|
+
|
|
642
|
+
{loading
|
|
643
|
+
? (
|
|
644
|
+
<Card>
|
|
645
|
+
<Flex justifyContent="center" marginY="size-400">
|
|
646
|
+
<ProgressCircle aria-label="Loading values" isIndeterminate />
|
|
647
|
+
</Flex>
|
|
648
|
+
</Card>
|
|
649
|
+
)
|
|
650
|
+
: (
|
|
651
|
+
(activeSection?.groups || []).map((group) => (
|
|
652
|
+
<GroupCard
|
|
653
|
+
key={group.id}
|
|
654
|
+
group={group}
|
|
655
|
+
sectionId={activeSection.id}
|
|
656
|
+
scope={scope}
|
|
657
|
+
collapsed={!!collapsedGroups[group.id]}
|
|
658
|
+
onToggle={() => toggleGroup(group.id)}
|
|
659
|
+
getDisplayValue={getDisplayValue}
|
|
660
|
+
getOrigin={getOrigin}
|
|
661
|
+
isInheritedAtScope={isInheritedAtScope}
|
|
662
|
+
setFieldValue={setFieldValue}
|
|
663
|
+
setUseDefault={setUseDefault}
|
|
664
|
+
sensitivePlaceholder={SENSITIVE_PLACEHOLDER}
|
|
665
|
+
/>
|
|
666
|
+
))
|
|
667
|
+
)}
|
|
668
|
+
|
|
669
|
+
<div style={{ height: 80 }} />
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
</>
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ----------------------------------------------------------------------------
|
|
677
|
+
// Custom scope picker — replaces Spectrum's Picker so we can render a clean
|
|
678
|
+
// hierarchical menu without the duplicate website-name section header that
|
|
679
|
+
// the Spectrum Section title forced.
|
|
680
|
+
// ----------------------------------------------------------------------------
|
|
681
|
+
function ScopePicker ({ scopeTreeForPicker, selectedKey, onChange, disabled }) {
|
|
682
|
+
const [open, setOpen] = useState(false)
|
|
683
|
+
const wrapperRef = useRef(null)
|
|
684
|
+
|
|
685
|
+
useEffect(() => {
|
|
686
|
+
if (!open) return
|
|
687
|
+
const onDoc = (e) => {
|
|
688
|
+
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) setOpen(false)
|
|
689
|
+
}
|
|
690
|
+
const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
|
|
691
|
+
document.addEventListener('mousedown', onDoc)
|
|
692
|
+
document.addEventListener('keydown', onKey)
|
|
693
|
+
return () => {
|
|
694
|
+
document.removeEventListener('mousedown', onDoc)
|
|
695
|
+
document.removeEventListener('keydown', onKey)
|
|
696
|
+
}
|
|
697
|
+
}, [open])
|
|
698
|
+
|
|
699
|
+
const selected = scopeTreeForPicker.all.find((o) => o.key === selectedKey)
|
|
700
|
+
const selectedLabel = selected?.label || 'Default Config'
|
|
701
|
+
|
|
702
|
+
const select = (key) => { onChange(key); setOpen(false) }
|
|
703
|
+
|
|
704
|
+
const renderItem = ({ key, label, indent = 0, isWebsite = false }) => {
|
|
705
|
+
const active = key === selectedKey
|
|
706
|
+
return (
|
|
707
|
+
<button
|
|
708
|
+
key={key}
|
|
709
|
+
type="button"
|
|
710
|
+
onClick={() => select(key)}
|
|
711
|
+
style={{
|
|
712
|
+
display: 'flex',
|
|
713
|
+
alignItems: 'center',
|
|
714
|
+
justifyContent: 'space-between',
|
|
715
|
+
width: '100%',
|
|
716
|
+
padding: `8px 12px 8px ${12 + indent * 18}px`,
|
|
717
|
+
background: active ? PALETTE.accentSoft : 'transparent',
|
|
718
|
+
color: active ? PALETTE.accent : PALETTE.text,
|
|
719
|
+
fontSize: 13,
|
|
720
|
+
fontWeight: active ? 700 : (isWebsite ? 600 : 500),
|
|
721
|
+
border: 0,
|
|
722
|
+
textAlign: 'left',
|
|
723
|
+
cursor: 'pointer',
|
|
724
|
+
font: 'inherit'
|
|
725
|
+
}}
|
|
726
|
+
onMouseOver={(e) => { if (!active) e.currentTarget.style.background = PALETTE.surfaceMuted }}
|
|
727
|
+
onMouseOut={(e) => { if (!active) e.currentTarget.style.background = 'transparent' }}
|
|
728
|
+
>
|
|
729
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontFamily: 'inherit' }}>
|
|
730
|
+
{indent > 0 && <span style={{ color: PALETTE.textMuted }}>↳</span>}
|
|
731
|
+
<span>{label}</span>
|
|
732
|
+
</span>
|
|
733
|
+
{active && <span style={{ color: PALETTE.accent, fontSize: 14 }}>✓</span>}
|
|
734
|
+
</button>
|
|
735
|
+
)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return (
|
|
739
|
+
<div ref={wrapperRef} style={{ position: 'relative' }}>
|
|
740
|
+
<button
|
|
741
|
+
type="button"
|
|
742
|
+
onClick={() => !disabled && setOpen((o) => !o)}
|
|
743
|
+
disabled={disabled}
|
|
744
|
+
aria-haspopup="listbox"
|
|
745
|
+
aria-expanded={open}
|
|
746
|
+
style={{
|
|
747
|
+
display: 'inline-flex',
|
|
748
|
+
alignItems: 'center',
|
|
749
|
+
gap: 8,
|
|
750
|
+
background: PALETTE.surface,
|
|
751
|
+
border: `1px solid ${PALETTE.border}`,
|
|
752
|
+
borderRadius: RADIUS.md,
|
|
753
|
+
padding: '6px 10px',
|
|
754
|
+
minWidth: 220,
|
|
755
|
+
fontFamily: 'inherit',
|
|
756
|
+
fontSize: 13,
|
|
757
|
+
fontWeight: 600,
|
|
758
|
+
color: PALETTE.text,
|
|
759
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
760
|
+
opacity: disabled ? 0.6 : 1
|
|
761
|
+
}}
|
|
762
|
+
>
|
|
763
|
+
<Globe size="XS" />
|
|
764
|
+
<span style={{ flex: 1, textAlign: 'left', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
765
|
+
{selectedLabel}
|
|
766
|
+
</span>
|
|
767
|
+
<span style={{ color: PALETTE.textMuted, fontSize: 11 }}>▾</span>
|
|
768
|
+
</button>
|
|
769
|
+
|
|
770
|
+
{open && (
|
|
771
|
+
<div
|
|
772
|
+
role="listbox"
|
|
773
|
+
style={{
|
|
774
|
+
position: 'absolute',
|
|
775
|
+
top: '100%',
|
|
776
|
+
right: 0,
|
|
777
|
+
marginTop: 4,
|
|
778
|
+
minWidth: 280,
|
|
779
|
+
maxHeight: 420,
|
|
780
|
+
overflowY: 'auto',
|
|
781
|
+
background: PALETTE.surface,
|
|
782
|
+
border: `1px solid ${PALETTE.border}`,
|
|
783
|
+
borderRadius: RADIUS.lg,
|
|
784
|
+
boxShadow: SHADOW.dropdown,
|
|
785
|
+
zIndex: 100,
|
|
786
|
+
padding: 4
|
|
787
|
+
}}
|
|
788
|
+
>
|
|
789
|
+
{renderItem({ key: scopeTreeForPicker.default.key, label: scopeTreeForPicker.default.label, indent: 0 })}
|
|
790
|
+
{scopeTreeForPicker.websites.map((w) => (
|
|
791
|
+
<div key={w.websiteId} style={{ marginTop: 6, paddingTop: 6, borderTop: `1px solid ${PALETTE.border}` }}>
|
|
792
|
+
<div style={{
|
|
793
|
+
padding: '6px 12px 4px',
|
|
794
|
+
fontSize: 10,
|
|
795
|
+
fontWeight: 700,
|
|
796
|
+
letterSpacing: 0.8,
|
|
797
|
+
textTransform: 'uppercase',
|
|
798
|
+
color: PALETTE.textMuted
|
|
799
|
+
}}>
|
|
800
|
+
Website
|
|
801
|
+
</div>
|
|
802
|
+
{renderItem({ key: w.websiteOption.key, label: w.websiteOption.label, indent: 0, isWebsite: true })}
|
|
803
|
+
{w.items.map((s) => renderItem({ key: s.key, label: s.label, indent: 1 }))}
|
|
804
|
+
</div>
|
|
805
|
+
))}
|
|
806
|
+
</div>
|
|
807
|
+
)}
|
|
808
|
+
</div>
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ----------------------------------------------------------------------------
|
|
813
|
+
// Top header (sticky)
|
|
814
|
+
// ----------------------------------------------------------------------------
|
|
815
|
+
function PageHeader ({
|
|
816
|
+
heroRef,
|
|
817
|
+
mode,
|
|
818
|
+
setMode,
|
|
819
|
+
scopeTree,
|
|
820
|
+
scopeTreeForPicker,
|
|
821
|
+
scopeKey,
|
|
822
|
+
onScopeChange,
|
|
823
|
+
onReloadStores,
|
|
824
|
+
onOpenTools,
|
|
825
|
+
toolsOpen
|
|
826
|
+
}) {
|
|
827
|
+
const isSchemaMode = mode === 'schema'
|
|
828
|
+
return (
|
|
829
|
+
<div
|
|
830
|
+
ref={heroRef}
|
|
831
|
+
style={{
|
|
832
|
+
// Hero card. Identical chrome to DataIngestion's hero — same border,
|
|
833
|
+
// radius, padding, shadow, font. Sticky so the title + scope picker
|
|
834
|
+
// stay reachable while scrolling long pages of fields.
|
|
835
|
+
position: 'sticky',
|
|
836
|
+
top: APP_NAV_OFFSET,
|
|
837
|
+
zIndex: 20,
|
|
838
|
+
background: PALETTE.surface,
|
|
839
|
+
border: `1px solid ${PALETTE.border}`,
|
|
840
|
+
borderRadius: RADIUS.xl,
|
|
841
|
+
padding: '20px 24px',
|
|
842
|
+
boxShadow: SHADOW.xs,
|
|
843
|
+
display: 'flex',
|
|
844
|
+
gap: 24,
|
|
845
|
+
alignItems: 'flex-start',
|
|
846
|
+
justifyContent: 'space-between',
|
|
847
|
+
flexWrap: 'wrap',
|
|
848
|
+
fontFamily: "adobe-clean, 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
|
849
|
+
}}
|
|
850
|
+
>
|
|
851
|
+
{/* Left: icon tile + eyebrow + title + subtitle */}
|
|
852
|
+
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', minWidth: 0 }}>
|
|
853
|
+
<div style={{
|
|
854
|
+
display: 'inline-flex',
|
|
855
|
+
padding: 10,
|
|
856
|
+
background: PALETTE.accentSoft,
|
|
857
|
+
color: PALETTE.accent,
|
|
858
|
+
borderRadius: RADIUS.lg,
|
|
859
|
+
flexShrink: 0
|
|
860
|
+
}}>
|
|
861
|
+
<Settings size="S" />
|
|
862
|
+
</div>
|
|
863
|
+
<div style={{ minWidth: 0 }}>
|
|
864
|
+
<div style={{
|
|
865
|
+
fontSize: 11, fontWeight: 700, letterSpacing: 0.6,
|
|
866
|
+
textTransform: 'uppercase', color: PALETTE.textMuted, marginBottom: 6
|
|
867
|
+
}}>
|
|
868
|
+
Configurations / App Builder
|
|
869
|
+
</div>
|
|
870
|
+
<div style={{ fontSize: 24, fontWeight: 700, color: PALETTE.text, lineHeight: 1.2 }}>
|
|
871
|
+
{isSchemaMode ? 'Schema Designer' : 'System Configuration'}
|
|
872
|
+
</div>
|
|
873
|
+
<div style={{ fontSize: 13, color: PALETTE.textMuted, marginTop: 6, maxWidth: 540 }}>
|
|
874
|
+
{isSchemaMode
|
|
875
|
+
? 'Define sections, groups, and fields. Renaming an id strands existing values; removing one prompts to delete its stored values.'
|
|
876
|
+
: 'Manage configuration values across Default Config, websites, and store views — stored in App Builder DB.'}
|
|
877
|
+
</div>
|
|
878
|
+
</div>
|
|
879
|
+
</div>
|
|
880
|
+
|
|
881
|
+
{/* Right: scope picker + action buttons + Back */}
|
|
882
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
883
|
+
{mode === 'values' && (
|
|
884
|
+
<>
|
|
885
|
+
<ScopePicker
|
|
886
|
+
scopeTreeForPicker={scopeTreeForPicker}
|
|
887
|
+
selectedKey={scopeKey}
|
|
888
|
+
onChange={onScopeChange}
|
|
889
|
+
disabled={scopeTree.loading}
|
|
890
|
+
/>
|
|
891
|
+
<TooltipTrigger>
|
|
892
|
+
<ActionButton onPress={onReloadStores} isDisabled={scopeTree.loading} aria-label="Reload stores">
|
|
893
|
+
<Refresh />
|
|
894
|
+
</ActionButton>
|
|
895
|
+
<Tooltip>Reload websites & stores from Commerce</Tooltip>
|
|
896
|
+
</TooltipTrigger>
|
|
897
|
+
<TooltipTrigger>
|
|
898
|
+
<ActionButton onPress={onOpenTools} aria-label="Open tools" isQuiet={!toolsOpen}>
|
|
899
|
+
<CloudUpload />
|
|
900
|
+
</ActionButton>
|
|
901
|
+
<Tooltip>Legacy migration tools</Tooltip>
|
|
902
|
+
</TooltipTrigger>
|
|
903
|
+
<TooltipTrigger>
|
|
904
|
+
<ActionButton onPress={() => setMode('schema')} aria-label="Edit schema">
|
|
905
|
+
<Edit />
|
|
906
|
+
</ActionButton>
|
|
907
|
+
<Tooltip>Edit schema</Tooltip>
|
|
908
|
+
</TooltipTrigger>
|
|
909
|
+
</>
|
|
910
|
+
)}
|
|
911
|
+
</div>
|
|
912
|
+
</div>
|
|
913
|
+
)
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ----------------------------------------------------------------------------
|
|
917
|
+
// Tools drawer (Export/Import + Commerce sync)
|
|
918
|
+
// ----------------------------------------------------------------------------
|
|
919
|
+
function ToolsPanel ({
|
|
920
|
+
onClose,
|
|
921
|
+
// Export / Import
|
|
922
|
+
onExport, exporting,
|
|
923
|
+
onImport, importing,
|
|
924
|
+
ioMsg,
|
|
925
|
+
ioProgress, // { phase, done, total, label }
|
|
926
|
+
importSourceKey, setImportSourceKey,
|
|
927
|
+
// Commerce sync
|
|
928
|
+
onSyncStoreMappings, syncingStoreMappings, syncMsg
|
|
929
|
+
}) {
|
|
930
|
+
return (
|
|
931
|
+
<Card style={{ marginBottom: 16 }}>
|
|
932
|
+
<Flex justifyContent="space-between" alignItems="center" marginBottom="size-150">
|
|
933
|
+
<Flex gap="size-100" alignItems="center">
|
|
934
|
+
<CloudUpload size="S" />
|
|
935
|
+
<Heading level={4} margin={0}>Export / Import</Heading>
|
|
936
|
+
</Flex>
|
|
937
|
+
<ActionButton isQuiet onPress={onClose} aria-label="Close tools">✕</ActionButton>
|
|
938
|
+
</Flex>
|
|
939
|
+
<Text UNSAFE_style={{ color: PALETTE.textMuted, fontSize: 13, display: 'block', marginBottom: 12 }}>
|
|
940
|
+
Download the entire configuration bundle as JSON for backup or to copy
|
|
941
|
+
between workspaces.
|
|
942
|
+
</Text>
|
|
943
|
+
<Flex gap="size-150" alignItems="center" wrap>
|
|
944
|
+
<Button variant="secondary" onPress={onExport} isDisabled={exporting || importing}>
|
|
945
|
+
{exporting ? 'Exporting…' : 'Export Configuration'}
|
|
946
|
+
</Button>
|
|
947
|
+
<Button variant="secondary" onPress={onImport} isDisabled={importing || exporting}>
|
|
948
|
+
{importing ? 'Importing…' : 'Import Configuration'}
|
|
949
|
+
</Button>
|
|
950
|
+
</Flex>
|
|
951
|
+
<View marginTop="size-150" UNSAFE_style={{ maxWidth: 520 }}>
|
|
952
|
+
<TextField
|
|
953
|
+
label="Source encryption key (only for legacy v1 dumps)"
|
|
954
|
+
type="password"
|
|
955
|
+
value={importSourceKey}
|
|
956
|
+
onChange={setImportSourceKey}
|
|
957
|
+
isDisabled={importing}
|
|
958
|
+
width="100%"
|
|
959
|
+
/>
|
|
960
|
+
</View>
|
|
961
|
+
|
|
962
|
+
{ioProgress && ioProgress.phase === 'running' && (
|
|
963
|
+
<View marginTop="size-200">
|
|
964
|
+
{ioProgress.total > 0
|
|
965
|
+
? (
|
|
966
|
+
<ProgressBar
|
|
967
|
+
label={ioProgress.label || 'Working…'}
|
|
968
|
+
value={ioProgress.done}
|
|
969
|
+
maxValue={ioProgress.total}
|
|
970
|
+
valueLabel={`${ioProgress.done} / ${ioProgress.total}`}
|
|
971
|
+
width="100%"
|
|
972
|
+
/>
|
|
973
|
+
)
|
|
974
|
+
: (
|
|
975
|
+
<ProgressBar
|
|
976
|
+
label={ioProgress.label || 'Working…'}
|
|
977
|
+
isIndeterminate
|
|
978
|
+
width="100%"
|
|
979
|
+
/>
|
|
980
|
+
)}
|
|
981
|
+
</View>
|
|
982
|
+
)}
|
|
983
|
+
{ioMsg && (
|
|
984
|
+
<View
|
|
985
|
+
marginTop="size-150"
|
|
986
|
+
padding="size-150"
|
|
987
|
+
UNSAFE_style={{
|
|
988
|
+
background: PALETTE.surface,
|
|
989
|
+
border: `1px solid ${PALETTE.border}`,
|
|
990
|
+
borderRadius: RADIUS.md
|
|
991
|
+
}}
|
|
992
|
+
>
|
|
993
|
+
<Text UNSAFE_style={{ whiteSpace: 'pre-line', fontSize: 13, fontFamily: 'ui-monospace, Menlo, monospace' }}>
|
|
994
|
+
{ioMsg}
|
|
995
|
+
</Text>
|
|
996
|
+
</View>
|
|
997
|
+
)}
|
|
998
|
+
|
|
999
|
+
<Divider size="S" marginY="size-250" />
|
|
1000
|
+
|
|
1001
|
+
<Flex justifyContent="space-between" alignItems="center" marginBottom="size-100">
|
|
1002
|
+
<Heading level={4} margin={0}>Sync Store Mappings</Heading>
|
|
1003
|
+
</Flex>
|
|
1004
|
+
<Text UNSAFE_style={{ color: PALETTE.textMuted, fontSize: 13, display: 'block', marginBottom: 12 }}>
|
|
1005
|
+
Rebuild <code>general/settings/store_mappings</code> from Commerce.
|
|
1006
|
+
</Text>
|
|
1007
|
+
<Flex gap="size-150" alignItems="center" wrap>
|
|
1008
|
+
<Button
|
|
1009
|
+
variant="secondary"
|
|
1010
|
+
onPress={onSyncStoreMappings}
|
|
1011
|
+
isDisabled={syncingStoreMappings || exporting || importing}
|
|
1012
|
+
>
|
|
1013
|
+
{syncingStoreMappings ? 'Syncing…' : 'Sync Store Mappings'}
|
|
1014
|
+
</Button>
|
|
1015
|
+
</Flex>
|
|
1016
|
+
{syncMsg && (
|
|
1017
|
+
<View
|
|
1018
|
+
marginTop="size-150"
|
|
1019
|
+
padding="size-150"
|
|
1020
|
+
UNSAFE_style={{
|
|
1021
|
+
background: PALETTE.surface,
|
|
1022
|
+
border: `1px solid ${PALETTE.border}`,
|
|
1023
|
+
borderRadius: RADIUS.md
|
|
1024
|
+
}}
|
|
1025
|
+
>
|
|
1026
|
+
<Text UNSAFE_style={{ whiteSpace: 'pre-line', fontSize: 13, fontFamily: 'ui-monospace, Menlo, monospace' }}>
|
|
1027
|
+
{syncMsg}
|
|
1028
|
+
</Text>
|
|
1029
|
+
</View>
|
|
1030
|
+
)}
|
|
1031
|
+
</Card>
|
|
1032
|
+
)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ----------------------------------------------------------------------------
|
|
1036
|
+
// Root
|
|
1037
|
+
// ----------------------------------------------------------------------------
|
|
1038
|
+
export default function SystemConfig (props) {
|
|
1039
|
+
const {
|
|
1040
|
+
schema,
|
|
1041
|
+
saveSchema,
|
|
1042
|
+
refresh: refreshSchema,
|
|
1043
|
+
loading: schemaLoading,
|
|
1044
|
+
saving: schemaSaving,
|
|
1045
|
+
error: schemaError
|
|
1046
|
+
} = useSystemConfigSchema(props)
|
|
1047
|
+
const [mode, setMode] = useState('values')
|
|
1048
|
+
const [toolsOpen, setToolsOpen] = useState(false)
|
|
1049
|
+
// Export / Import state
|
|
1050
|
+
const [exporting, setExporting] = useState(false)
|
|
1051
|
+
const [importing, setImporting] = useState(false)
|
|
1052
|
+
const [ioMsg, setIoMsg] = useState(null)
|
|
1053
|
+
// { phase: 'idle'|'running'|'done'|'error', done, total, label }
|
|
1054
|
+
const [ioProgress, setIoProgress] = useState({ phase: 'idle', done: 0, total: 0, label: '' })
|
|
1055
|
+
// Optional SOURCE env's SYSTEM_CONFIG_CRYPT_KEY for cross-env imports.
|
|
1056
|
+
const [importSourceKey, setImportSourceKey] = useState('')
|
|
1057
|
+
// Store-mapping sync state
|
|
1058
|
+
const [syncingStoreMappings, setSyncingStoreMappings] = useState(false)
|
|
1059
|
+
const [syncMsg, setSyncMsg] = useState(null)
|
|
1060
|
+
const { confirm, dialog: confirmDialog } = useConfirm()
|
|
1061
|
+
|
|
1062
|
+
// Measure the hero card so the sticky save bar / sidebar always sit
|
|
1063
|
+
// flush against its bottom — even when the subtitle wraps to a different
|
|
1064
|
+
// line count on narrow viewports. Exposed via a CSS variable that the
|
|
1065
|
+
// save bar and sidebars (in this file and in SystemConfigSchemaEditor)
|
|
1066
|
+
// consume via calc().
|
|
1067
|
+
const heroRef = useRef(null)
|
|
1068
|
+
useEffect(() => {
|
|
1069
|
+
if (!heroRef.current) return undefined
|
|
1070
|
+
const update = () => {
|
|
1071
|
+
const h = heroRef.current ? heroRef.current.offsetHeight : HERO_HEIGHT
|
|
1072
|
+
document.documentElement.style.setProperty('--sc-hero-h', `${h}px`)
|
|
1073
|
+
}
|
|
1074
|
+
update()
|
|
1075
|
+
const ro = new ResizeObserver(update)
|
|
1076
|
+
ro.observe(heroRef.current)
|
|
1077
|
+
return () => { ro.disconnect() }
|
|
1078
|
+
}, [mode])
|
|
1079
|
+
|
|
1080
|
+
// Single source of truth — both the sticky header (scope picker) and the
|
|
1081
|
+
// ValuesView render against the same hook instance. Previously each rendered
|
|
1082
|
+
// their own copy of useSystemConfig and changes never propagated.
|
|
1083
|
+
const configCtx = useSystemConfig(
|
|
1084
|
+
props,
|
|
1085
|
+
mode === 'values' ? schema : { sections: [] }
|
|
1086
|
+
)
|
|
1087
|
+
const { scope, setScope, scopeTree, refreshScopeTree } = configCtx
|
|
1088
|
+
const scopeTreeForPicker = useMemo(() => buildScopeTreeForPicker(scopeTree), [scopeTree])
|
|
1089
|
+
const scopeKey = `${scope.scope}::${scope.scopeId}`
|
|
1090
|
+
const onScopeChange = (key) => {
|
|
1091
|
+
const opt = scopeTreeForPicker.all.find((o) => o.key === key)
|
|
1092
|
+
if (!opt) return
|
|
1093
|
+
setScope({ scope: opt.scope, scopeId: opt.scopeId })
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const onSchemaSave = async (next) => {
|
|
1097
|
+
let result = await saveSchema(next)
|
|
1098
|
+
if (result?.needsConfirmation) {
|
|
1099
|
+
const removed = result.removedPaths || []
|
|
1100
|
+
const ok = await confirm({
|
|
1101
|
+
title: 'Removing schema entries will delete stored values',
|
|
1102
|
+
body:
|
|
1103
|
+
'The following field path(s) are being removed from the schema. ' +
|
|
1104
|
+
'Their values will be permanently deleted from system_config_data ' +
|
|
1105
|
+
'across every scope:\n\n • ' + removed.join('\n • ') + '\n\n' +
|
|
1106
|
+
'Continue?',
|
|
1107
|
+
confirmLabel: 'Delete & save',
|
|
1108
|
+
cancelLabel: 'Cancel',
|
|
1109
|
+
variant: 'destructive'
|
|
1110
|
+
})
|
|
1111
|
+
if (!ok) return
|
|
1112
|
+
result = await saveSchema(next, { confirmCascade: true })
|
|
1113
|
+
}
|
|
1114
|
+
if (!result?.ok) return
|
|
1115
|
+
if ((result.deletedCount || 0) > 0) {
|
|
1116
|
+
// Refresh values too so UI reflects the deletions.
|
|
1117
|
+
try { await configCtx.refresh() } catch (_) {}
|
|
1118
|
+
}
|
|
1119
|
+
setMode('values')
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// ── Export configuration → JSON file download ───────────────────────────
|
|
1123
|
+
const onExport = async () => {
|
|
1124
|
+
setExporting(true)
|
|
1125
|
+
setIoMsg(null)
|
|
1126
|
+
setIoProgress({ phase: 'running', done: 0, total: 0, label: 'Collecting schema + values from ABDB…' })
|
|
1127
|
+
try {
|
|
1128
|
+
const response = await callAction(
|
|
1129
|
+
props,
|
|
1130
|
+
getActionKey('exportConfig'),
|
|
1131
|
+
'',
|
|
1132
|
+
{}
|
|
1133
|
+
)
|
|
1134
|
+
const dump = response?.dump || response?.body?.dump
|
|
1135
|
+
if (!dump) throw new Error('Export response missing `dump`')
|
|
1136
|
+
|
|
1137
|
+
setIoProgress(p => ({ ...p, label: 'Building file…' }))
|
|
1138
|
+
const blob = new Blob([JSON.stringify(dump, null, 2)], { type: 'application/json' })
|
|
1139
|
+
const filename = `system-config-export-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
|
1140
|
+
const url = URL.createObjectURL(blob)
|
|
1141
|
+
const a = document.createElement('a')
|
|
1142
|
+
a.href = url
|
|
1143
|
+
a.download = filename
|
|
1144
|
+
document.body.appendChild(a)
|
|
1145
|
+
a.click()
|
|
1146
|
+
document.body.removeChild(a)
|
|
1147
|
+
URL.revokeObjectURL(url)
|
|
1148
|
+
|
|
1149
|
+
const c = dump.counts || {}
|
|
1150
|
+
setIoProgress({ phase: 'done', done: c.values || 0, total: c.values || 0, label: 'Export complete' })
|
|
1151
|
+
setIoMsg(`✓ Exported ${c.sections ?? '?'} section(s) and ${c.values ?? '?'} value(s) → ${filename}`)
|
|
1152
|
+
} catch (e) {
|
|
1153
|
+
console.error('Export failed', e)
|
|
1154
|
+
setIoProgress({ phase: 'error', done: 0, total: 0, label: 'Export failed' })
|
|
1155
|
+
setIoMsg(`Export failed: ${e.message || e}`)
|
|
1156
|
+
} finally {
|
|
1157
|
+
setExporting(false)
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// ── Import configuration ← JSON file picker ─────────────────────────────
|
|
1162
|
+
// Imports a previously-exported dump in client-side chunks so we can drive a
|
|
1163
|
+
// determinate ProgressBar (instead of a single opaque 5-minute call). On the
|
|
1164
|
+
// backend, import-config translates website_id/store_id by matching codes
|
|
1165
|
+
// against the target env's current `general/settings/store_mappings`, so be
|
|
1166
|
+
// sure to Sync store_mappings before importing into a fresh env.
|
|
1167
|
+
const IMPORT_CHUNK_SIZE = 25
|
|
1168
|
+
const onImport = async () => {
|
|
1169
|
+
// Open file picker
|
|
1170
|
+
const input = document.createElement('input')
|
|
1171
|
+
input.type = 'file'
|
|
1172
|
+
input.accept = '.json,application/json'
|
|
1173
|
+
input.style.display = 'none'
|
|
1174
|
+
document.body.appendChild(input)
|
|
1175
|
+
|
|
1176
|
+
const file = await new Promise((resolve) => {
|
|
1177
|
+
input.onchange = () => { resolve(input.files && input.files[0]) }
|
|
1178
|
+
input.click()
|
|
1179
|
+
})
|
|
1180
|
+
document.body.removeChild(input)
|
|
1181
|
+
if (!file) return
|
|
1182
|
+
|
|
1183
|
+
let dump
|
|
1184
|
+
try {
|
|
1185
|
+
const text = await file.text()
|
|
1186
|
+
dump = JSON.parse(text)
|
|
1187
|
+
} catch (e) {
|
|
1188
|
+
setIoMsg(`Could not parse "${file.name}": ${e.message}`)
|
|
1189
|
+
return
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Modern multi-choice confirm — pick overwrite vs insert-only.
|
|
1193
|
+
const choice = await confirm({
|
|
1194
|
+
title: `Import "${file.name}"?`,
|
|
1195
|
+
variant: 'information',
|
|
1196
|
+
body: (
|
|
1197
|
+
<span>
|
|
1198
|
+
Schema + values from this dump will be applied to the current
|
|
1199
|
+
workspace. website_id / store_id are remapped on the fly by matching
|
|
1200
|
+
<code> website_code </code> and store <code>code</code> against the
|
|
1201
|
+
target environment's Commerce instance.
|
|
1202
|
+
</span>
|
|
1203
|
+
),
|
|
1204
|
+
choices: [
|
|
1205
|
+
{
|
|
1206
|
+
label: 'Overwrite existing values',
|
|
1207
|
+
value: 'overwrite',
|
|
1208
|
+
variant: 'destructive',
|
|
1209
|
+
description: 'Recommended for restoring a backup. Existing rows are replaced.'
|
|
1210
|
+
},
|
|
1211
|
+
{
|
|
1212
|
+
label: 'Insert-only',
|
|
1213
|
+
value: 'insert',
|
|
1214
|
+
variant: 'information',
|
|
1215
|
+
description: 'Skip rows that already exist; only add new ones.'
|
|
1216
|
+
}
|
|
1217
|
+
],
|
|
1218
|
+
cancelLabel: 'Cancel'
|
|
1219
|
+
})
|
|
1220
|
+
if (!choice) return
|
|
1221
|
+
const overwrite = choice === 'overwrite'
|
|
1222
|
+
|
|
1223
|
+
const allValues = Array.isArray(dump.values) ? dump.values : []
|
|
1224
|
+
const schemaPayload = dump.schema
|
|
1225
|
+
const total = allValues.length
|
|
1226
|
+
|
|
1227
|
+
setImporting(true)
|
|
1228
|
+
setIoMsg(null)
|
|
1229
|
+
setIoProgress({
|
|
1230
|
+
phase: 'running',
|
|
1231
|
+
done: 0,
|
|
1232
|
+
total,
|
|
1233
|
+
label: schemaPayload ? 'Importing schema…' : 'Importing values…'
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
const aggregate = {
|
|
1237
|
+
schemaImported: false,
|
|
1238
|
+
schemaSkipped: false,
|
|
1239
|
+
valuesInserted: 0,
|
|
1240
|
+
valuesUpserted: 0,
|
|
1241
|
+
valuesSkipped: 0,
|
|
1242
|
+
unmappedSkipped: 0,
|
|
1243
|
+
unmapped: [],
|
|
1244
|
+
invalid: [],
|
|
1245
|
+
idMap: null,
|
|
1246
|
+
sensitiveReencrypted: 0,
|
|
1247
|
+
sensitiveDecryptFailed: 0
|
|
1248
|
+
}
|
|
1249
|
+
const sensitiveCount = allValues.filter(
|
|
1250
|
+
v => typeof v?.value === 'string' && v.value.startsWith('enc:v1:')
|
|
1251
|
+
).length
|
|
1252
|
+
|
|
1253
|
+
try {
|
|
1254
|
+
// 1) Schema (only on the first call). We still send the source
|
|
1255
|
+
// store_mappings each chunk so the backend can build the id map.
|
|
1256
|
+
if (schemaPayload) {
|
|
1257
|
+
const r = await callAction(
|
|
1258
|
+
props,
|
|
1259
|
+
getActionKey('importConfig'),
|
|
1260
|
+
'',
|
|
1261
|
+
{ schema: schemaPayload, overwrite, valuesOnly: false, schemaOnly: true }
|
|
1262
|
+
)
|
|
1263
|
+
const s = r?.summary || r?.body?.summary
|
|
1264
|
+
if (s) {
|
|
1265
|
+
aggregate.schemaImported = !!s.schemaImported
|
|
1266
|
+
aggregate.schemaSkipped = !!s.schemaSkipped
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// 2) Values in chunks. The backend resolves scope_id remap by reading
|
|
1271
|
+
// the target env's Commerce live and matching each row's
|
|
1272
|
+
// `scope_code` (stamped by export-config v2+) — no source mapping
|
|
1273
|
+
// needs to be carried in the dump.
|
|
1274
|
+
// sensitivePaths (added by export-config v2): tells the backend which
|
|
1275
|
+
// paths must be encrypted with the local key. Without this list the
|
|
1276
|
+
// backend has to derive it from the schema in ABDB, which may not be
|
|
1277
|
+
// present yet on a fresh import.
|
|
1278
|
+
const sensitivePaths = Array.isArray(dump.sensitivePaths) ? dump.sensitivePaths : undefined
|
|
1279
|
+
|
|
1280
|
+
setIoProgress(p => ({ ...p, label: 'Importing values…' }))
|
|
1281
|
+
for (let i = 0; i < total; i += IMPORT_CHUNK_SIZE) {
|
|
1282
|
+
const chunk = allValues.slice(i, i + IMPORT_CHUNK_SIZE)
|
|
1283
|
+
const r = await callAction(
|
|
1284
|
+
props,
|
|
1285
|
+
getActionKey('importConfig'),
|
|
1286
|
+
'',
|
|
1287
|
+
{
|
|
1288
|
+
values: chunk,
|
|
1289
|
+
overwrite,
|
|
1290
|
+
valuesOnly: true,
|
|
1291
|
+
// Re-encrypt sensitive ciphertext against the target env's key.
|
|
1292
|
+
sourceCryptKey: importSourceKey ? importSourceKey.trim() : undefined,
|
|
1293
|
+
// sensitivePaths on every chunk so the backend knows what to
|
|
1294
|
+
// encrypt even before the schema row lands.
|
|
1295
|
+
dump: sensitivePaths ? { sensitivePaths } : undefined
|
|
1296
|
+
}
|
|
1297
|
+
)
|
|
1298
|
+
const s = r?.summary || r?.body?.summary
|
|
1299
|
+
if (s) {
|
|
1300
|
+
aggregate.valuesInserted += s.valuesInserted || 0
|
|
1301
|
+
aggregate.valuesUpserted += s.valuesUpserted || 0
|
|
1302
|
+
aggregate.valuesSkipped += s.valuesSkipped || 0
|
|
1303
|
+
aggregate.unmappedSkipped += s.unmappedSkipped || 0
|
|
1304
|
+
aggregate.sensitiveReencrypted += s.sensitiveReencrypted || 0
|
|
1305
|
+
aggregate.sensitiveDecryptFailed += s.sensitiveDecryptFailed || 0
|
|
1306
|
+
if (Array.isArray(s.unmapped)) aggregate.unmapped.push(...s.unmapped)
|
|
1307
|
+
if (Array.isArray(s.invalid)) aggregate.invalid.push(...s.invalid)
|
|
1308
|
+
if (s.idMap) {
|
|
1309
|
+
if (!aggregate.idMap) {
|
|
1310
|
+
aggregate.idMap = { ...s.idMap }
|
|
1311
|
+
} else {
|
|
1312
|
+
aggregate.idMap.matchedByCode = (aggregate.idMap.matchedByCode || 0) + (s.idMap.matchedByCode || 0)
|
|
1313
|
+
aggregate.idMap.matchedById = (aggregate.idMap.matchedById || 0) + (s.idMap.matchedById || 0)
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
setIoProgress({
|
|
1318
|
+
phase: 'running',
|
|
1319
|
+
done: Math.min(i + chunk.length, total),
|
|
1320
|
+
total,
|
|
1321
|
+
label: `Importing values… (${Math.min(i + chunk.length, total)}/${total})`
|
|
1322
|
+
})
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const lines = [
|
|
1326
|
+
`✓ Import complete (${overwrite ? 'overwrite' : 'insert-only'})`,
|
|
1327
|
+
` Schema: ${aggregate.schemaImported ? 'imported' : aggregate.schemaSkipped ? 'skipped (exists)' : 'no schema in dump'}`,
|
|
1328
|
+
` Values: inserted=${aggregate.valuesInserted} upserted=${aggregate.valuesUpserted} skipped=${aggregate.valuesSkipped}`,
|
|
1329
|
+
aggregate.unmappedSkipped
|
|
1330
|
+
? ` ⚠ Unmapped rows skipped (no matching website_code/store_code in target): ${aggregate.unmappedSkipped}`
|
|
1331
|
+
: '',
|
|
1332
|
+
sensitiveCount
|
|
1333
|
+
? ` Sensitive: ${sensitiveCount} ciphertext row(s) in dump → re-encrypted=${aggregate.sensitiveReencrypted}, decrypt-failed=${aggregate.sensitiveDecryptFailed}${
|
|
1334
|
+
importSourceKey ? '' : ' (no source key provided — values may show blank if this env\'s key differs)'
|
|
1335
|
+
}`
|
|
1336
|
+
: '',
|
|
1337
|
+
aggregate.invalid.length ? ` ⚠ Invalid rows: ${aggregate.invalid.length}` : '',
|
|
1338
|
+
aggregate.idMap
|
|
1339
|
+
? [
|
|
1340
|
+
` id remap → target(${aggregate.idMap.targetSource || 'none'}, websites=${aggregate.idMap.targetWebsiteCount || 0}, stores=${aggregate.idMap.targetStoreCount || 0}) matched(by-code=${aggregate.idMap.matchedByCode || 0}, by-id=${aggregate.idMap.matchedById || 0})`,
|
|
1341
|
+
!aggregate.idMap.hasTarget ? ' ⚠ Target env Commerce returned no stores — check COMMERCE_BASE_URL / OAuth1 secrets in this workspace.' : ''
|
|
1342
|
+
].filter(Boolean).join('\n')
|
|
1343
|
+
: ''
|
|
1344
|
+
].filter(Boolean)
|
|
1345
|
+
setIoMsg(lines.join('\n'))
|
|
1346
|
+
setIoProgress({ phase: 'done', done: total, total, label: 'Import complete' })
|
|
1347
|
+
await refreshSchema()
|
|
1348
|
+
try { await configCtx.refresh() } catch (_) {}
|
|
1349
|
+
} catch (e) {
|
|
1350
|
+
console.error('Import failed', e)
|
|
1351
|
+
setIoProgress(p => ({ ...p, phase: 'error', label: 'Import failed' }))
|
|
1352
|
+
setIoMsg(`Import failed: ${e.message || e}`)
|
|
1353
|
+
} finally {
|
|
1354
|
+
setImporting(false)
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// ── Sync Store Mappings from Commerce REST ──────────────────────────────
|
|
1359
|
+
const onSyncStoreMappings = async () => {
|
|
1360
|
+
setSyncingStoreMappings(true)
|
|
1361
|
+
setSyncMsg('Fetching websites + store views from Commerce…')
|
|
1362
|
+
try {
|
|
1363
|
+
const response = await callAction(
|
|
1364
|
+
props,
|
|
1365
|
+
getActionKey('syncStoreMappings'),
|
|
1366
|
+
'',
|
|
1367
|
+
{}
|
|
1368
|
+
)
|
|
1369
|
+
const ok = response?.ok ?? response?.body?.ok
|
|
1370
|
+
const count = response?.count ?? response?.body?.count
|
|
1371
|
+
const mapping = response?.mapping ?? response?.body?.mapping
|
|
1372
|
+
if (!ok) throw new Error('Sync response missing `ok`')
|
|
1373
|
+
const sample = mapping
|
|
1374
|
+
? Object.entries(mapping).slice(0, 5).map(([id, m]) =>
|
|
1375
|
+
` ${id}: ${m.code} → website ${m.website_code}(${m.website_id}), lang=${m.language_code}`
|
|
1376
|
+
).join('\n')
|
|
1377
|
+
: ''
|
|
1378
|
+
setSyncMsg(
|
|
1379
|
+
`✓ Synced ${count} store(s) → general/settings/store_mappings\n` +
|
|
1380
|
+
(sample ? sample + (count > 5 ? `\n … (${count - 5} more)` : '') : '')
|
|
1381
|
+
)
|
|
1382
|
+
try { await configCtx.refresh() } catch (_) {}
|
|
1383
|
+
} catch (e) {
|
|
1384
|
+
console.error('Store-mapping sync failed', e)
|
|
1385
|
+
setSyncMsg(`Sync failed: ${e.message || e}`)
|
|
1386
|
+
} finally {
|
|
1387
|
+
setSyncingStoreMappings(false)
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
return (
|
|
1392
|
+
<View
|
|
1393
|
+
UNSAFE_style={{
|
|
1394
|
+
background: PALETTE.bg,
|
|
1395
|
+
minHeight: '100vh',
|
|
1396
|
+
color: PALETTE.text
|
|
1397
|
+
}}
|
|
1398
|
+
>
|
|
1399
|
+
{confirmDialog}
|
|
1400
|
+
<View padding="size-400" maxWidth="1400px" marginX="auto">
|
|
1401
|
+
<PageHeader
|
|
1402
|
+
heroRef={heroRef}
|
|
1403
|
+
mode={mode}
|
|
1404
|
+
setMode={setMode}
|
|
1405
|
+
scopeTree={scopeTree}
|
|
1406
|
+
scopeTreeForPicker={scopeTreeForPicker}
|
|
1407
|
+
scopeKey={scopeKey}
|
|
1408
|
+
onScopeChange={onScopeChange}
|
|
1409
|
+
onReloadStores={refreshScopeTree}
|
|
1410
|
+
onOpenTools={() => setToolsOpen((o) => !o)}
|
|
1411
|
+
toolsOpen={toolsOpen}
|
|
1412
|
+
/>
|
|
1413
|
+
|
|
1414
|
+
<div style={{ paddingTop: 24 }}>
|
|
1415
|
+
{toolsOpen && mode === 'values' && (
|
|
1416
|
+
<ToolsPanel
|
|
1417
|
+
onClose={() => setToolsOpen(false)}
|
|
1418
|
+
onExport={onExport}
|
|
1419
|
+
exporting={exporting}
|
|
1420
|
+
onImport={onImport}
|
|
1421
|
+
importing={importing}
|
|
1422
|
+
ioMsg={ioMsg}
|
|
1423
|
+
ioProgress={ioProgress}
|
|
1424
|
+
importSourceKey={importSourceKey}
|
|
1425
|
+
setImportSourceKey={setImportSourceKey}
|
|
1426
|
+
onSyncStoreMappings={onSyncStoreMappings}
|
|
1427
|
+
syncingStoreMappings={syncingStoreMappings}
|
|
1428
|
+
syncMsg={syncMsg}
|
|
1429
|
+
/>
|
|
1430
|
+
)}
|
|
1431
|
+
|
|
1432
|
+
{schemaLoading
|
|
1433
|
+
? (
|
|
1434
|
+
<Card>
|
|
1435
|
+
<Flex justifyContent="center" marginY="size-400">
|
|
1436
|
+
<ProgressCircle aria-label="Loading schema" isIndeterminate />
|
|
1437
|
+
</Flex>
|
|
1438
|
+
</Card>
|
|
1439
|
+
)
|
|
1440
|
+
: mode === 'schema'
|
|
1441
|
+
? (
|
|
1442
|
+
<SystemConfigSchemaEditor
|
|
1443
|
+
schema={schema}
|
|
1444
|
+
onSave={onSchemaSave}
|
|
1445
|
+
onCancel={() => setMode('values')}
|
|
1446
|
+
saving={schemaSaving}
|
|
1447
|
+
error={schemaError}
|
|
1448
|
+
palette={PALETTE}
|
|
1449
|
+
/>
|
|
1450
|
+
)
|
|
1451
|
+
: (
|
|
1452
|
+
<ValuesView
|
|
1453
|
+
schema={schema}
|
|
1454
|
+
onEditSchema={() => setMode('schema')}
|
|
1455
|
+
toolsOpen={toolsOpen}
|
|
1456
|
+
setToolsOpen={setToolsOpen}
|
|
1457
|
+
configCtx={configCtx}
|
|
1458
|
+
/>
|
|
1459
|
+
)}
|
|
1460
|
+
</div>
|
|
1461
|
+
</View>
|
|
1462
|
+
</View>
|
|
1463
|
+
)
|
|
1464
|
+
}
|