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,355 @@
|
|
|
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 React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
9
|
+
import ReactDOM from 'react-dom'
|
|
10
|
+
import { PALETTE, RADIUS, SHADOW } from '../theme'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Promise-based confirmation dialog rendered as a portal modal. Does NOT
|
|
14
|
+
* depend on Spectrum's DialogContainer / DialogTrigger — those didn't fire
|
|
15
|
+
* reliably from the redesigned shell, so this implementation owns the
|
|
16
|
+
* overlay, focus, ESC handling, and resolve lifecycle directly.
|
|
17
|
+
*
|
|
18
|
+
* Returns:
|
|
19
|
+
* - confirm(options) → Promise<boolean>
|
|
20
|
+
* - dialog — JSX element to render once at the page root
|
|
21
|
+
*
|
|
22
|
+
* Options:
|
|
23
|
+
* - title (default: "Are you sure?")
|
|
24
|
+
* - body (string | ReactNode)
|
|
25
|
+
* - confirmLabel (default: "Confirm")
|
|
26
|
+
* - cancelLabel (default: "Cancel")
|
|
27
|
+
* - variant ('confirmation' | 'destructive' | 'warning' | 'information')
|
|
28
|
+
* - choices (optional) array of { label, value, variant?, description? }
|
|
29
|
+
* When provided, the confirm/cancel buttons are replaced by
|
|
30
|
+
* this set. Resolves to the chosen `value` (or null when
|
|
31
|
+
* cancelled via overlay/Esc).
|
|
32
|
+
*/
|
|
33
|
+
export function useConfirm () {
|
|
34
|
+
const [state, setState] = useState(null) // { options } | null
|
|
35
|
+
const resolverRef = useRef(null)
|
|
36
|
+
|
|
37
|
+
const confirm = useCallback((opts = {}) => {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
resolverRef.current = resolve
|
|
40
|
+
setState({ options: opts })
|
|
41
|
+
})
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
const finish = useCallback((result) => {
|
|
45
|
+
const resolve = resolverRef.current
|
|
46
|
+
resolverRef.current = null
|
|
47
|
+
setState(null)
|
|
48
|
+
if (resolve) resolve(result)
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
// Esc to cancel
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!state) return
|
|
54
|
+
const onKey = (e) => {
|
|
55
|
+
if (e.key === 'Escape') finish(state.options && state.options.choices ? null : false)
|
|
56
|
+
}
|
|
57
|
+
window.addEventListener('keydown', onKey)
|
|
58
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
59
|
+
}, [state, finish])
|
|
60
|
+
|
|
61
|
+
// Lock body scroll while open
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!state) return
|
|
64
|
+
const prev = document.body.style.overflow
|
|
65
|
+
document.body.style.overflow = 'hidden'
|
|
66
|
+
return () => { document.body.style.overflow = prev }
|
|
67
|
+
}, [state])
|
|
68
|
+
|
|
69
|
+
const dialog = state
|
|
70
|
+
? ReactDOM.createPortal(
|
|
71
|
+
<ConfirmModal
|
|
72
|
+
options={state.options}
|
|
73
|
+
onConfirm={() => finish(true)}
|
|
74
|
+
onCancel={() => finish(state.options && state.options.choices ? null : false)}
|
|
75
|
+
onChoose={(value) => finish(value)}
|
|
76
|
+
/>,
|
|
77
|
+
document.body
|
|
78
|
+
)
|
|
79
|
+
: null
|
|
80
|
+
|
|
81
|
+
return { confirm, dialog }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const VARIANT_STYLES = {
|
|
85
|
+
destructive: { color: PALETTE.danger, primaryBg: PALETTE.danger, primaryBgHover: PALETTE.dangerHover, tint: PALETTE.dangerTint, icon: '⚠' },
|
|
86
|
+
warning: { color: PALETTE.warning, primaryBg: PALETTE.warning, primaryBgHover: PALETTE.warningHover, tint: PALETTE.warningTint, icon: '!' },
|
|
87
|
+
information: { color: PALETTE.accent, primaryBg: PALETTE.accent, primaryBgHover: PALETTE.accentHover, tint: PALETTE.accentTint, icon: 'i' },
|
|
88
|
+
confirmation: { color: PALETTE.accent, primaryBg: PALETTE.accent, primaryBgHover: PALETTE.accentHover, tint: PALETTE.accentTint, icon: '?' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ConfirmModal ({ options, onConfirm, onCancel, onChoose }) {
|
|
92
|
+
const variant = options.variant || 'confirmation'
|
|
93
|
+
const styles = VARIANT_STYLES[variant] || VARIANT_STYLES.confirmation
|
|
94
|
+
const confirmRef = useRef(null)
|
|
95
|
+
const hasChoices = Array.isArray(options.choices) && options.choices.length > 0
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
// Focus the primary action so Enter confirms.
|
|
99
|
+
if (confirmRef.current) confirmRef.current.focus()
|
|
100
|
+
}, [])
|
|
101
|
+
|
|
102
|
+
const renderBody = (body) => {
|
|
103
|
+
if (body == null) return null
|
|
104
|
+
if (typeof body !== 'string') return body
|
|
105
|
+
return body.split('\n').map((line, i) => (
|
|
106
|
+
<React.Fragment key={i}>
|
|
107
|
+
{line}
|
|
108
|
+
{i < body.split('\n').length - 1 && <br />}
|
|
109
|
+
</React.Fragment>
|
|
110
|
+
))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Spectrum font stack so the modal blends with the rest of the admin UI
|
|
114
|
+
// instead of falling back to the browser's serif default.
|
|
115
|
+
const SPECTRUM_FONT = "adobe-clean, 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, 'Trebuchet MS', 'Lucida Grande', sans-serif"
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
role="dialog"
|
|
120
|
+
aria-modal="true"
|
|
121
|
+
aria-labelledby="confirm-title"
|
|
122
|
+
style={{
|
|
123
|
+
position: 'fixed',
|
|
124
|
+
inset: 0,
|
|
125
|
+
zIndex: 100000,
|
|
126
|
+
background: PALETTE.overlay,
|
|
127
|
+
backdropFilter: 'blur(2px)',
|
|
128
|
+
display: 'flex',
|
|
129
|
+
alignItems: 'center',
|
|
130
|
+
justifyContent: 'center',
|
|
131
|
+
padding: 16,
|
|
132
|
+
fontFamily: SPECTRUM_FONT,
|
|
133
|
+
animation: 'sm-fade-in 120ms ease-out'
|
|
134
|
+
}}
|
|
135
|
+
onClick={(e) => { if (e.target === e.currentTarget) onCancel() }}
|
|
136
|
+
>
|
|
137
|
+
<div
|
|
138
|
+
style={{
|
|
139
|
+
background: PALETTE.surface,
|
|
140
|
+
borderRadius: RADIUS.xl,
|
|
141
|
+
boxShadow: SHADOW.modal,
|
|
142
|
+
width: '100%',
|
|
143
|
+
maxWidth: 520,
|
|
144
|
+
maxHeight: 'calc(100vh - 32px)',
|
|
145
|
+
display: 'flex',
|
|
146
|
+
flexDirection: 'column',
|
|
147
|
+
overflow: 'hidden',
|
|
148
|
+
animation: 'sm-pop-in 160ms cubic-bezier(0.16, 1, 0.3, 1)'
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<div
|
|
152
|
+
style={{
|
|
153
|
+
padding: '20px 24px 12px',
|
|
154
|
+
display: 'flex',
|
|
155
|
+
alignItems: 'flex-start',
|
|
156
|
+
gap: 14
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
<div
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
style={{
|
|
162
|
+
flex: '0 0 auto',
|
|
163
|
+
width: 36,
|
|
164
|
+
height: 36,
|
|
165
|
+
borderRadius: RADIUS.pill,
|
|
166
|
+
background: styles.tint,
|
|
167
|
+
color: styles.color,
|
|
168
|
+
display: 'flex',
|
|
169
|
+
alignItems: 'center',
|
|
170
|
+
justifyContent: 'center',
|
|
171
|
+
fontSize: 18,
|
|
172
|
+
fontWeight: 700,
|
|
173
|
+
lineHeight: 1,
|
|
174
|
+
fontFamily: SPECTRUM_FONT
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
{styles.icon}
|
|
178
|
+
</div>
|
|
179
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
180
|
+
<div
|
|
181
|
+
id="confirm-title"
|
|
182
|
+
style={{
|
|
183
|
+
fontFamily: SPECTRUM_FONT,
|
|
184
|
+
fontSize: 17,
|
|
185
|
+
fontWeight: 700,
|
|
186
|
+
lineHeight: 1.3,
|
|
187
|
+
letterSpacing: '-0.005em',
|
|
188
|
+
color: PALETTE.textStrong
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
{options.title || 'Are you sure?'}
|
|
192
|
+
</div>
|
|
193
|
+
{options.body != null && (
|
|
194
|
+
<div
|
|
195
|
+
style={{
|
|
196
|
+
marginTop: 6,
|
|
197
|
+
fontFamily: SPECTRUM_FONT,
|
|
198
|
+
color: PALETTE.textSoft,
|
|
199
|
+
fontSize: 13,
|
|
200
|
+
lineHeight: 1.55,
|
|
201
|
+
maxHeight: '40vh',
|
|
202
|
+
overflowY: 'auto'
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
{renderBody(options.body)}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{hasChoices
|
|
212
|
+
? (
|
|
213
|
+
<div
|
|
214
|
+
style={{
|
|
215
|
+
padding: '4px 16px 16px',
|
|
216
|
+
display: 'flex',
|
|
217
|
+
flexDirection: 'column',
|
|
218
|
+
gap: 8
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
{options.choices.map((c, i) => {
|
|
222
|
+
const cStyles = VARIANT_STYLES[c.variant] || VARIANT_STYLES.confirmation
|
|
223
|
+
const isPrimary = i === 0
|
|
224
|
+
return (
|
|
225
|
+
<button
|
|
226
|
+
key={c.value ?? i}
|
|
227
|
+
type="button"
|
|
228
|
+
ref={isPrimary ? confirmRef : null}
|
|
229
|
+
onClick={() => onChoose(c.value)}
|
|
230
|
+
style={{
|
|
231
|
+
textAlign: 'left',
|
|
232
|
+
padding: '10px 14px',
|
|
233
|
+
borderRadius: RADIUS.lg,
|
|
234
|
+
border: isPrimary ? `1px solid ${cStyles.primaryBg}` : `1px solid ${PALETTE.borderStrong}`,
|
|
235
|
+
background: isPrimary ? cStyles.primaryBg : PALETTE.surface,
|
|
236
|
+
color: isPrimary ? PALETTE.textInverse : PALETTE.textStrong,
|
|
237
|
+
fontFamily: SPECTRUM_FONT,
|
|
238
|
+
fontSize: 14,
|
|
239
|
+
fontWeight: 600,
|
|
240
|
+
lineHeight: 1.35,
|
|
241
|
+
cursor: 'pointer',
|
|
242
|
+
transition: 'background 120ms ease, border-color 120ms ease',
|
|
243
|
+
display: 'flex',
|
|
244
|
+
flexDirection: 'column',
|
|
245
|
+
gap: 2
|
|
246
|
+
}}
|
|
247
|
+
onMouseOver={(e) => {
|
|
248
|
+
e.currentTarget.style.background = isPrimary ? cStyles.primaryBgHover : PALETTE.surfaceMuted
|
|
249
|
+
if (isPrimary) e.currentTarget.style.borderColor = cStyles.primaryBgHover
|
|
250
|
+
}}
|
|
251
|
+
onMouseOut={(e) => {
|
|
252
|
+
e.currentTarget.style.background = isPrimary ? cStyles.primaryBg : PALETTE.surface
|
|
253
|
+
if (isPrimary) e.currentTarget.style.borderColor = cStyles.primaryBg
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
256
|
+
<span>{c.label}</span>
|
|
257
|
+
{c.description && (
|
|
258
|
+
<span
|
|
259
|
+
style={{
|
|
260
|
+
fontSize: 12,
|
|
261
|
+
fontWeight: 400,
|
|
262
|
+
opacity: isPrimary ? 0.9 : 0.7
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
{c.description}
|
|
266
|
+
</span>
|
|
267
|
+
)}
|
|
268
|
+
</button>
|
|
269
|
+
)
|
|
270
|
+
})}
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
onClick={onCancel}
|
|
274
|
+
style={{
|
|
275
|
+
marginTop: 4,
|
|
276
|
+
padding: '8px 14px',
|
|
277
|
+
borderRadius: RADIUS.lg,
|
|
278
|
+
border: '1px solid transparent',
|
|
279
|
+
background: 'transparent',
|
|
280
|
+
color: PALETTE.textMuted,
|
|
281
|
+
fontFamily: SPECTRUM_FONT,
|
|
282
|
+
fontSize: 13,
|
|
283
|
+
fontWeight: 600,
|
|
284
|
+
lineHeight: 1.3,
|
|
285
|
+
cursor: 'pointer'
|
|
286
|
+
}}
|
|
287
|
+
onMouseOver={(e) => { e.currentTarget.style.background = PALETTE.surfaceMuted }}
|
|
288
|
+
onMouseOut={(e) => { e.currentTarget.style.background = 'transparent' }}
|
|
289
|
+
>
|
|
290
|
+
{options.cancelLabel || 'Cancel'}
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
)
|
|
294
|
+
: (
|
|
295
|
+
<div
|
|
296
|
+
style={{
|
|
297
|
+
padding: '12px 16px',
|
|
298
|
+
background: PALETTE.surfacePanel,
|
|
299
|
+
borderTop: `1px solid ${PALETTE.border}`,
|
|
300
|
+
display: 'flex',
|
|
301
|
+
justifyContent: 'flex-end',
|
|
302
|
+
gap: 10
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
onClick={onCancel}
|
|
308
|
+
style={{
|
|
309
|
+
padding: '8px 16px',
|
|
310
|
+
minHeight: 36,
|
|
311
|
+
borderRadius: RADIUS.md,
|
|
312
|
+
border: `1px solid ${PALETTE.borderStrong}`,
|
|
313
|
+
background: PALETTE.surface,
|
|
314
|
+
color: PALETTE.textStrong,
|
|
315
|
+
fontFamily: SPECTRUM_FONT,
|
|
316
|
+
fontSize: 14,
|
|
317
|
+
fontWeight: 600,
|
|
318
|
+
lineHeight: 1.3,
|
|
319
|
+
cursor: 'pointer',
|
|
320
|
+
transition: 'background 120ms ease'
|
|
321
|
+
}}
|
|
322
|
+
onMouseOver={(e) => { e.currentTarget.style.background = PALETTE.surfaceMuted }}
|
|
323
|
+
onMouseOut={(e) => { e.currentTarget.style.background = PALETTE.surface }}
|
|
324
|
+
>
|
|
325
|
+
{options.cancelLabel || 'Cancel'}
|
|
326
|
+
</button>
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
ref={confirmRef}
|
|
330
|
+
onClick={onConfirm}
|
|
331
|
+
style={{
|
|
332
|
+
padding: '8px 16px',
|
|
333
|
+
minHeight: 36,
|
|
334
|
+
borderRadius: RADIUS.md,
|
|
335
|
+
border: `1px solid ${styles.primaryBg}`,
|
|
336
|
+
background: styles.primaryBg,
|
|
337
|
+
color: PALETTE.textInverse,
|
|
338
|
+
fontFamily: SPECTRUM_FONT,
|
|
339
|
+
fontSize: 14,
|
|
340
|
+
fontWeight: 600,
|
|
341
|
+
lineHeight: 1.3,
|
|
342
|
+
cursor: 'pointer',
|
|
343
|
+
transition: 'background 120ms ease'
|
|
344
|
+
}}
|
|
345
|
+
onMouseOver={(e) => { e.currentTarget.style.background = styles.primaryBgHover; e.currentTarget.style.borderColor = styles.primaryBgHover }}
|
|
346
|
+
onMouseOut={(e) => { e.currentTarget.style.background = styles.primaryBg; e.currentTarget.style.borderColor = styles.primaryBg }}
|
|
347
|
+
>
|
|
348
|
+
{options.confirmLabel || 'Confirm'}
|
|
349
|
+
</button>
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
)
|
|
355
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
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 { useCallback, useEffect, useMemo, useState } from 'react'
|
|
9
|
+
import { callAction } from '../utils'
|
|
10
|
+
import { getActionKey } from '../settings'
|
|
11
|
+
import { flattenFields, isFieldVisibleAtScope } from '../schema/systemConfigSchema'
|
|
12
|
+
import { buildStoreMappingsFromCommercePayload } from '../utils/storeMappingsFromCommerceRest'
|
|
13
|
+
|
|
14
|
+
const SENSITIVE_PLACEHOLDER = '__SENSITIVE_UNCHANGED__'
|
|
15
|
+
const USE_DEFAULT_SENTINEL = '__USE_DEFAULT__'
|
|
16
|
+
const DEFAULT_SCOPE = { scope: 'default', scopeId: '0' }
|
|
17
|
+
const STORE_MAPPINGS_PATH = 'general/settings/store_mappings'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} props
|
|
21
|
+
* @param {object} schema – the dynamic schema fetched from abdb via useSystemConfigSchema
|
|
22
|
+
*/
|
|
23
|
+
export function useSystemConfig (props, schema) {
|
|
24
|
+
const fields = useMemo(() => flattenFields(schema), [schema])
|
|
25
|
+
const allPaths = useMemo(() => fields.map((f) => f.path), [fields])
|
|
26
|
+
const sensitivePaths = useMemo(
|
|
27
|
+
() => fields.filter((f) => f.sensitive).map((f) => f.path),
|
|
28
|
+
[fields]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const [scopeTree, setScopeTree] = useState({ websites: [], storeGroups: [], stores: [], loading: true, error: null })
|
|
32
|
+
const [scope, setScope] = useState(DEFAULT_SCOPE)
|
|
33
|
+
|
|
34
|
+
const [serverItems, setServerItems] = useState({})
|
|
35
|
+
const [localValues, setLocalValues] = useState({})
|
|
36
|
+
const [loading, setLoading] = useState(false)
|
|
37
|
+
const [saving, setSaving] = useState(false)
|
|
38
|
+
const [error, setError] = useState(null)
|
|
39
|
+
const [savedAt, setSavedAt] = useState(null)
|
|
40
|
+
|
|
41
|
+
const parentWebsiteId = useMemo(() => {
|
|
42
|
+
if (scope.scope !== 'stores') return undefined
|
|
43
|
+
const store = scopeTree.stores.find((s) => String(s.id) === String(scope.scopeId))
|
|
44
|
+
return store?.website_id
|
|
45
|
+
}, [scope, scopeTree.stores])
|
|
46
|
+
|
|
47
|
+
const fetchScopeTree = useCallback(async () => {
|
|
48
|
+
setScopeTree((prev) => ({ ...prev, loading: true, error: null }))
|
|
49
|
+
try {
|
|
50
|
+
// commerce-rest-get appends `rest/<store>/V1/` itself, so operation
|
|
51
|
+
// must be the resource path without the rest/V1 prefix.
|
|
52
|
+
const [websitesRes, groupsRes, storesRes, configsRes] = await Promise.all([
|
|
53
|
+
callAction(props, getActionKey('commerceRestGet'), 'store/websites'),
|
|
54
|
+
callAction(props, getActionKey('commerceRestGet'), 'store/storeGroups'),
|
|
55
|
+
callAction(props, getActionKey('commerceRestGet'), 'store/storeViews'),
|
|
56
|
+
callAction(props, getActionKey('commerceRestGet'), 'store/storeConfigs').catch(() => null)
|
|
57
|
+
])
|
|
58
|
+
const websitesRaw = websitesRes?.body || websitesRes
|
|
59
|
+
const groupsRaw = groupsRes?.body || groupsRes
|
|
60
|
+
const storesRaw = storesRes?.body || storesRes
|
|
61
|
+
const configsRaw = configsRes?.body || configsRes
|
|
62
|
+
const websites = Array.isArray(websitesRaw) ? websitesRaw.filter((w) => w.id !== 0 && w.code !== 'admin') : []
|
|
63
|
+
const storeGroups = Array.isArray(groupsRaw) ? groupsRaw.filter((g) => g.id !== 0) : []
|
|
64
|
+
const stores = Array.isArray(storesRaw) ? storesRaw.filter((s) => s.id !== 0 && s.code !== 'admin') : []
|
|
65
|
+
setScopeTree({ websites, storeGroups, stores, loading: false, error: null })
|
|
66
|
+
|
|
67
|
+
const storeMappings = buildStoreMappingsFromCommercePayload(websitesRaw, storesRaw, configsRaw)
|
|
68
|
+
if (Object.keys(storeMappings).length > 0) {
|
|
69
|
+
try {
|
|
70
|
+
await callAction(props, getActionKey('systemConfigSave'), '', {
|
|
71
|
+
values: { [STORE_MAPPINGS_PATH]: JSON.stringify(storeMappings, null, 2) },
|
|
72
|
+
sensitivePaths: [],
|
|
73
|
+
scope: 'default',
|
|
74
|
+
scopeId: '0'
|
|
75
|
+
})
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error('Failed to persist store_mappings to ABDB after loading Commerce stores', err)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
console.error('Failed to load stores from Commerce', e)
|
|
82
|
+
setScopeTree({ websites: [], storeGroups: [], stores: [], loading: false, error: e.message || 'Failed to fetch stores' })
|
|
83
|
+
}
|
|
84
|
+
}, [props])
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
fetchScopeTree()
|
|
88
|
+
}, [fetchScopeTree])
|
|
89
|
+
|
|
90
|
+
const fetchAtScope = useCallback(async () => {
|
|
91
|
+
if (allPaths.length === 0) {
|
|
92
|
+
setServerItems({})
|
|
93
|
+
setLocalValues({})
|
|
94
|
+
setLoading(false)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
setLoading(true)
|
|
98
|
+
setError(null)
|
|
99
|
+
try {
|
|
100
|
+
const response = await callAction(
|
|
101
|
+
props,
|
|
102
|
+
getActionKey('systemConfigList'),
|
|
103
|
+
'',
|
|
104
|
+
{
|
|
105
|
+
paths: allPaths,
|
|
106
|
+
sensitivePaths,
|
|
107
|
+
scope: scope.scope,
|
|
108
|
+
scopeId: scope.scopeId,
|
|
109
|
+
parentWebsiteId
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
const items = response?.items || response?.body?.items || {}
|
|
113
|
+
setServerItems(items)
|
|
114
|
+
setLocalValues({})
|
|
115
|
+
} catch (e) {
|
|
116
|
+
console.error('Failed to load system config', e)
|
|
117
|
+
setError(e.message || 'Failed to load system config')
|
|
118
|
+
} finally {
|
|
119
|
+
setLoading(false)
|
|
120
|
+
}
|
|
121
|
+
}, [props, allPaths, sensitivePaths, scope, parentWebsiteId])
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
fetchAtScope()
|
|
125
|
+
}, [fetchAtScope])
|
|
126
|
+
|
|
127
|
+
const getDisplayValue = useCallback((path, fallback) => {
|
|
128
|
+
if (Object.prototype.hasOwnProperty.call(localValues, path)) {
|
|
129
|
+
return localValues[path]
|
|
130
|
+
}
|
|
131
|
+
const item = serverItems[path]
|
|
132
|
+
if (item && item.value !== undefined) return item.value
|
|
133
|
+
return fallback
|
|
134
|
+
}, [localValues, serverItems])
|
|
135
|
+
|
|
136
|
+
const getOrigin = useCallback((path) => {
|
|
137
|
+
const item = serverItems[path]
|
|
138
|
+
return item?.origin || null
|
|
139
|
+
}, [serverItems])
|
|
140
|
+
|
|
141
|
+
const isInheritedAtScope = useCallback((path) => {
|
|
142
|
+
if (scope.scope === 'default') return false
|
|
143
|
+
if (Object.prototype.hasOwnProperty.call(localValues, path)) {
|
|
144
|
+
return localValues[path] === USE_DEFAULT_SENTINEL
|
|
145
|
+
}
|
|
146
|
+
const origin = getOrigin(path)
|
|
147
|
+
if (!origin) return true
|
|
148
|
+
return !(origin.scope === scope.scope && String(origin.scopeId) === String(scope.scopeId))
|
|
149
|
+
}, [scope, localValues, getOrigin])
|
|
150
|
+
|
|
151
|
+
const setFieldValue = useCallback((path, value) => {
|
|
152
|
+
setLocalValues((prev) => ({ ...prev, [path]: value }))
|
|
153
|
+
}, [])
|
|
154
|
+
|
|
155
|
+
const setUseDefault = useCallback((path, useDefault) => {
|
|
156
|
+
setLocalValues((prev) => {
|
|
157
|
+
const next = { ...prev }
|
|
158
|
+
if (useDefault) {
|
|
159
|
+
next[path] = USE_DEFAULT_SENTINEL
|
|
160
|
+
} else {
|
|
161
|
+
const current = serverItems[path]?.value
|
|
162
|
+
next[path] = current !== undefined ? current : ''
|
|
163
|
+
}
|
|
164
|
+
return next
|
|
165
|
+
})
|
|
166
|
+
}, [serverItems])
|
|
167
|
+
|
|
168
|
+
const dirtyCount = useMemo(() => Object.keys(localValues).length, [localValues])
|
|
169
|
+
|
|
170
|
+
const save = useCallback(async () => {
|
|
171
|
+
if (dirtyCount === 0) return true
|
|
172
|
+
setSaving(true)
|
|
173
|
+
setError(null)
|
|
174
|
+
try {
|
|
175
|
+
const visibleFieldsByPath = new Map(
|
|
176
|
+
fields
|
|
177
|
+
.filter((f) => isFieldVisibleAtScope(f.field, scope.scope))
|
|
178
|
+
.map((f) => [f.path, f])
|
|
179
|
+
)
|
|
180
|
+
const payload = {}
|
|
181
|
+
for (const [path, value] of Object.entries(localValues)) {
|
|
182
|
+
if (!visibleFieldsByPath.has(path)) continue
|
|
183
|
+
payload[path] = value
|
|
184
|
+
}
|
|
185
|
+
if (Object.keys(payload).length === 0) {
|
|
186
|
+
setSaving(false)
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
await callAction(
|
|
190
|
+
props,
|
|
191
|
+
getActionKey('systemConfigSave'),
|
|
192
|
+
'',
|
|
193
|
+
{
|
|
194
|
+
values: payload,
|
|
195
|
+
sensitivePaths,
|
|
196
|
+
scope: scope.scope,
|
|
197
|
+
scopeId: scope.scopeId
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
setSavedAt(Date.now())
|
|
201
|
+
await fetchAtScope()
|
|
202
|
+
return true
|
|
203
|
+
} catch (e) {
|
|
204
|
+
console.error('Failed to save system config', e)
|
|
205
|
+
setError(e.message || 'Failed to save system config')
|
|
206
|
+
return false
|
|
207
|
+
} finally {
|
|
208
|
+
setSaving(false)
|
|
209
|
+
}
|
|
210
|
+
}, [props, dirtyCount, localValues, sensitivePaths, scope, fields, fetchAtScope])
|
|
211
|
+
|
|
212
|
+
const reset = useCallback(() => {
|
|
213
|
+
setLocalValues({})
|
|
214
|
+
}, [])
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
fields,
|
|
218
|
+
scope,
|
|
219
|
+
setScope,
|
|
220
|
+
scopeTree,
|
|
221
|
+
refreshScopeTree: fetchScopeTree,
|
|
222
|
+
getDisplayValue,
|
|
223
|
+
getOrigin,
|
|
224
|
+
isInheritedAtScope,
|
|
225
|
+
setFieldValue,
|
|
226
|
+
setUseDefault,
|
|
227
|
+
dirtyCount,
|
|
228
|
+
loading,
|
|
229
|
+
saving,
|
|
230
|
+
error,
|
|
231
|
+
savedAt,
|
|
232
|
+
save,
|
|
233
|
+
reset,
|
|
234
|
+
refresh: fetchAtScope,
|
|
235
|
+
SENSITIVE_PLACEHOLDER,
|
|
236
|
+
USE_DEFAULT_SENTINEL
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
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 { useCallback, useEffect, useState } from 'react'
|
|
9
|
+
import { callAction } from '../utils'
|
|
10
|
+
import { getActionKey } from '../settings'
|
|
11
|
+
import { emptySchema } from '../schema/systemConfigSchema'
|
|
12
|
+
|
|
13
|
+
export function useSystemConfigSchema (props) {
|
|
14
|
+
const [schema, setSchema] = useState(emptySchema())
|
|
15
|
+
const [loading, setLoading] = useState(true)
|
|
16
|
+
const [saving, setSaving] = useState(false)
|
|
17
|
+
const [error, setError] = useState(null)
|
|
18
|
+
|
|
19
|
+
const fetchSchema = useCallback(async () => {
|
|
20
|
+
setLoading(true)
|
|
21
|
+
setError(null)
|
|
22
|
+
try {
|
|
23
|
+
const response = await callAction(
|
|
24
|
+
props,
|
|
25
|
+
getActionKey('systemConfigSchema'),
|
|
26
|
+
'get'
|
|
27
|
+
)
|
|
28
|
+
const fetched = response?.schema || response?.body?.schema || emptySchema()
|
|
29
|
+
setSchema(fetched)
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.error('Failed to load schema', e)
|
|
32
|
+
setError(e.message || 'Failed to load schema')
|
|
33
|
+
setSchema(emptySchema())
|
|
34
|
+
} finally {
|
|
35
|
+
setLoading(false)
|
|
36
|
+
}
|
|
37
|
+
}, [props])
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
fetchSchema()
|
|
41
|
+
}, [fetchSchema])
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Save a schema.
|
|
45
|
+
*
|
|
46
|
+
* If removing fields/groups/sections would orphan stored values, the action
|
|
47
|
+
* returns 409 with a `removedPaths` list. The caller can re-invoke with
|
|
48
|
+
* `confirmCascade: true` to acknowledge the cascade-delete. The hook keeps
|
|
49
|
+
* the UI in charge of asking the user.
|
|
50
|
+
*/
|
|
51
|
+
const saveSchema = useCallback(async (nextSchema, { confirmCascade = false } = {}) => {
|
|
52
|
+
setSaving(true)
|
|
53
|
+
setError(null)
|
|
54
|
+
try {
|
|
55
|
+
let response
|
|
56
|
+
try {
|
|
57
|
+
response = await callAction(
|
|
58
|
+
props,
|
|
59
|
+
getActionKey('systemConfigSchema'),
|
|
60
|
+
'save',
|
|
61
|
+
{ schema: nextSchema, ...(confirmCascade ? { confirmCascade: true } : {}) }
|
|
62
|
+
)
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// 409 = cascade confirmation required. callAction throws on non-2xx
|
|
65
|
+
// so we have to inspect err.response (set by utils.callAction).
|
|
66
|
+
const removed = err?.response?.removedPaths || err?.response?.body?.removedPaths
|
|
67
|
+
if (err?.status === 409 && Array.isArray(removed)) {
|
|
68
|
+
return { needsConfirmation: true, removedPaths: removed }
|
|
69
|
+
}
|
|
70
|
+
throw err
|
|
71
|
+
}
|
|
72
|
+
const saved = response?.schema || response?.body?.schema
|
|
73
|
+
if (!saved) {
|
|
74
|
+
await fetchSchema()
|
|
75
|
+
setError('Schema save did not return the saved schema. See server logs.')
|
|
76
|
+
return { ok: false }
|
|
77
|
+
}
|
|
78
|
+
setSchema(saved)
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
removedPaths: response?.removedPaths || response?.body?.removedPaths || [],
|
|
82
|
+
deletedCount: response?.deletedCount ?? response?.body?.deletedCount ?? 0
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.error('Failed to save schema', e)
|
|
86
|
+
setError(e.message || 'Failed to save schema')
|
|
87
|
+
return { ok: false }
|
|
88
|
+
} finally {
|
|
89
|
+
setSaving(false)
|
|
90
|
+
}
|
|
91
|
+
}, [props, fetchSchema])
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
schema,
|
|
95
|
+
setSchema,
|
|
96
|
+
saveSchema,
|
|
97
|
+
refresh: fetchSchema,
|
|
98
|
+
loading,
|
|
99
|
+
saving,
|
|
100
|
+
error
|
|
101
|
+
}
|
|
102
|
+
}
|