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.
Files changed (35) hide show
  1. package/README.md +199 -0
  2. package/actions/configurations/commerce/index.js +55 -0
  3. package/actions/configurations/export-config/index.js +259 -0
  4. package/actions/configurations/ext.config.yaml +151 -0
  5. package/actions/configurations/import-config/index.js +544 -0
  6. package/actions/configurations/registration/index.js +37 -0
  7. package/actions/configurations/sync-store-mappings-from-commerce/index.js +199 -0
  8. package/actions/configurations/system-config-list/index.js +127 -0
  9. package/actions/configurations/system-config-save/index.js +160 -0
  10. package/actions/configurations/system-config-schema/index.js +327 -0
  11. package/actions/utils.js +73 -0
  12. package/package.json +74 -0
  13. package/scripts/setup-app-config.js +114 -0
  14. package/src/abdb-config.js +241 -0
  15. package/src/abdb-helper.js +476 -0
  16. package/src/index.js +20 -0
  17. package/src/oauth1a.js +135 -0
  18. package/src/system-config-crypto.js +113 -0
  19. package/src/system-config-shared.js +89 -0
  20. package/web/src/components/App.js +47 -0
  21. package/web/src/components/AppSectionNav.js +49 -0
  22. package/web/src/components/ExtensionRegistration.js +33 -0
  23. package/web/src/components/MainPage.js +46 -0
  24. package/web/src/components/SystemConfig.js +1464 -0
  25. package/web/src/components/SystemConfigSchemaEditor.js +459 -0
  26. package/web/src/hooks/useConfirm.js +355 -0
  27. package/web/src/hooks/useSystemConfig.js +238 -0
  28. package/web/src/hooks/useSystemConfigSchema.js +102 -0
  29. package/web/src/index.js +41 -0
  30. package/web/src/schema/systemConfigSchema.js +82 -0
  31. package/web/src/settings.js +57 -0
  32. package/web/src/styles/index.css +326 -0
  33. package/web/src/theme.js +104 -0
  34. package/web/src/utils/storeMappingsFromCommerceRest.js +73 -0
  35. 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
+ }