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,459 @@
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 { useEffect, useMemo, useState } from 'react'
9
+ import {
10
+ View,
11
+ Flex,
12
+ Heading,
13
+ Text,
14
+ Button,
15
+ ActionButton,
16
+ TextField,
17
+ Picker,
18
+ Item,
19
+ Switch,
20
+ Checkbox,
21
+ Divider,
22
+ Well,
23
+ ProgressCircle
24
+ } from '@adobe/react-spectrum'
25
+ import { FIELD_TYPES, SCOPES, emptySchema } from '../schema/systemConfigSchema'
26
+ import { useConfirm } from '../hooks/useConfirm'
27
+ import { PALETTE, RADIUS, SHADOW } from '../theme'
28
+
29
+ const ID_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/
30
+
31
+ function blankField () {
32
+ return {
33
+ id: '',
34
+ label: '',
35
+ type: 'text',
36
+ default: '',
37
+ showIn: ['default'],
38
+ sensitive: false,
39
+ options: []
40
+ }
41
+ }
42
+
43
+ function blankGroup () {
44
+ return { id: '', label: '', fields: [] }
45
+ }
46
+
47
+ function blankSection () {
48
+ return { id: '', label: '', groups: [] }
49
+ }
50
+
51
+ function cloneSchema (schema) {
52
+ return JSON.parse(JSON.stringify(schema || emptySchema()))
53
+ }
54
+
55
+ function validateLocal (schema) {
56
+ if (!Array.isArray(schema.sections)) return 'sections must be an array'
57
+ const seenSection = new Set()
58
+ for (const s of schema.sections) {
59
+ if (!ID_RE.test(s.id || '')) return `Section id "${s.id}" is invalid (start with letter, [a-zA-Z0-9_])`
60
+ if (seenSection.has(s.id)) return `Duplicate section id "${s.id}"`
61
+ seenSection.add(s.id)
62
+ if (!s.label?.trim()) return `Section ${s.id}: label required`
63
+ const seenGroup = new Set()
64
+ for (const g of s.groups || []) {
65
+ if (!ID_RE.test(g.id || '')) return `${s.id}: group id "${g.id}" is invalid`
66
+ if (seenGroup.has(g.id)) return `${s.id}: duplicate group id "${g.id}"`
67
+ seenGroup.add(g.id)
68
+ if (!g.label?.trim()) return `${s.id}.${g.id}: label required`
69
+ const seenField = new Set()
70
+ for (const f of g.fields || []) {
71
+ if (!ID_RE.test(f.id || '')) return `${s.id}.${g.id}: field id "${f.id}" is invalid`
72
+ if (seenField.has(f.id)) return `${s.id}.${g.id}: duplicate field id "${f.id}"`
73
+ seenField.add(f.id)
74
+ if (!f.label?.trim()) return `${s.id}.${g.id}.${f.id}: label required`
75
+ if (!FIELD_TYPES.includes(f.type)) return `${s.id}.${g.id}.${f.id}: unknown type "${f.type}"`
76
+ if (!Array.isArray(f.showIn) || f.showIn.length === 0) {
77
+ return `${s.id}.${g.id}.${f.id}: pick at least one scope in showIn`
78
+ }
79
+ if (f.type === 'select' && (!Array.isArray(f.options) || f.options.length === 0)) {
80
+ return `${s.id}.${g.id}.${f.id}: select fields need at least one option`
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return null
86
+ }
87
+
88
+ function FieldEditor ({ field, onChange, onRemove }) {
89
+ const update = (patch) => onChange({ ...field, ...patch })
90
+
91
+ const addOption = () => {
92
+ update({ options: [...(field.options || []), { value: '', label: '' }] })
93
+ }
94
+ const updateOption = (i, patch) => {
95
+ const next = [...(field.options || [])]
96
+ next[i] = { ...next[i], ...patch }
97
+ update({ options: next })
98
+ }
99
+ const removeOption = (i) => {
100
+ const next = [...(field.options || [])]
101
+ next.splice(i, 1)
102
+ update({ options: next })
103
+ }
104
+
105
+ return (
106
+ <div style={{
107
+ background: PALETTE.surfaceSubtle,
108
+ border: `1px solid ${PALETTE.border}`,
109
+ borderRadius: RADIUS.md,
110
+ padding: 14,
111
+ marginBottom: 10
112
+ }}>
113
+ <Flex gap="size-150" wrap alignItems="end">
114
+ <TextField label="Field ID" value={field.id} onChange={(v) => update({ id: v })} width="size-2400" />
115
+ <TextField label="Label" value={field.label} onChange={(v) => update({ label: v })} width="size-3000" />
116
+ <Picker label="Type" selectedKey={field.type} onSelectionChange={(k) => update({ type: k })} width="size-2000">
117
+ {FIELD_TYPES.map((t) => <Item key={t}>{t}</Item>)}
118
+ </Picker>
119
+ <TextField
120
+ label="Default"
121
+ value={field.default == null ? '' : String(field.default)}
122
+ onChange={(v) => update({ default: v })}
123
+ width="size-2400"
124
+ />
125
+ <ActionButton onPress={onRemove}>Remove field</ActionButton>
126
+ </Flex>
127
+
128
+ <Flex gap="size-200" marginTop="size-150" wrap alignItems="center">
129
+ <Text>Visible in:</Text>
130
+ {SCOPES.map((scope) => (
131
+ <Checkbox
132
+ key={scope}
133
+ isSelected={(field.showIn || []).includes(scope)}
134
+ onChange={(checked) => {
135
+ const set = new Set(field.showIn || [])
136
+ if (checked) set.add(scope)
137
+ else set.delete(scope)
138
+ update({ showIn: Array.from(set) })
139
+ }}
140
+ >
141
+ {scope}
142
+ </Checkbox>
143
+ ))}
144
+ <Switch isSelected={!!field.sensitive} onChange={(v) => update({ sensitive: v })}>
145
+ Sensitive (encrypt at rest)
146
+ </Switch>
147
+ </Flex>
148
+
149
+ {field.type === 'select' && (
150
+ <View marginTop="size-150">
151
+ <Text>Options</Text>
152
+ {(field.options || []).map((opt, i) => (
153
+ <Flex key={i} gap="size-100" marginTop="size-75" alignItems="end">
154
+ <TextField label="Value" value={opt.value} onChange={(v) => updateOption(i, { value: v })} width="size-2400" />
155
+ <TextField label="Label" value={opt.label} onChange={(v) => updateOption(i, { label: v })} width="size-3000" />
156
+ <ActionButton onPress={() => removeOption(i)}>Remove</ActionButton>
157
+ </Flex>
158
+ ))}
159
+ <Button variant="secondary" marginTop="size-100" onPress={addOption}>+ Add option</Button>
160
+ </View>
161
+ )}
162
+ </div>
163
+ )
164
+ }
165
+
166
+ function GroupEditor ({ group, onChange, onRemove }) {
167
+ const update = (patch) => onChange({ ...group, ...patch })
168
+
169
+ const addField = () => update({ fields: [...(group.fields || []), blankField()] })
170
+ const updateField = (i, next) => {
171
+ const fields = [...(group.fields || [])]
172
+ fields[i] = next
173
+ update({ fields })
174
+ }
175
+ const removeField = (i) => {
176
+ const fields = [...(group.fields || [])]
177
+ fields.splice(i, 1)
178
+ update({ fields })
179
+ }
180
+
181
+ return (
182
+ <div style={{
183
+ background: PALETTE.surface,
184
+ border: `1px solid ${PALETTE.border}`,
185
+ borderRadius: RADIUS.lg,
186
+ boxShadow: SHADOW.xs,
187
+ padding: 20,
188
+ marginBottom: 16
189
+ }}>
190
+ <Flex gap="size-200" alignItems="end" marginBottom="size-150" wrap>
191
+ <TextField label="Group ID" value={group.id} onChange={(v) => update({ id: v })} width="size-2400" />
192
+ <TextField label="Group Label" value={group.label} onChange={(v) => update({ label: v })} width="size-3600" />
193
+ <ActionButton onPress={onRemove}>Remove group</ActionButton>
194
+ </Flex>
195
+ <Divider size="S" marginBottom="size-150" />
196
+ {(group.fields || []).map((f, i) => (
197
+ <FieldEditor
198
+ key={i}
199
+ field={f}
200
+ onChange={(next) => updateField(i, next)}
201
+ onRemove={() => removeField(i)}
202
+ />
203
+ ))}
204
+ <Button variant="secondary" onPress={addField}>+ Add field</Button>
205
+ </div>
206
+ )
207
+ }
208
+
209
+ export default function SystemConfigSchemaEditor ({ schema, onSave, onCancel, saving, error, palette }) {
210
+ const [draft, setDraft] = useState(() => cloneSchema(schema))
211
+ const [activeSectionIdx, setActiveSectionIdx] = useState(0)
212
+ const [localError, setLocalError] = useState(null)
213
+ const { confirm, dialog: confirmDialog } = useConfirm()
214
+
215
+ useEffect(() => {
216
+ setDraft(cloneSchema(schema))
217
+ }, [schema])
218
+
219
+ const activeSection = draft.sections[activeSectionIdx]
220
+
221
+ const updateSection = (idx, patch) => {
222
+ setDraft((prev) => {
223
+ const next = cloneSchema(prev)
224
+ next.sections[idx] = { ...next.sections[idx], ...patch }
225
+ return next
226
+ })
227
+ }
228
+
229
+ const addSection = () => {
230
+ setDraft((prev) => {
231
+ const next = cloneSchema(prev)
232
+ next.sections.push(blankSection())
233
+ return next
234
+ })
235
+ setActiveSectionIdx(draft.sections.length)
236
+ }
237
+
238
+ const removeSection = async (idx) => {
239
+ const label = draft.sections[idx]?.label || draft.sections[idx]?.id || `section ${idx + 1}`
240
+ const ok = await confirm({
241
+ title: 'Remove section?',
242
+ body: `"${label}" and all of its groups/fields will be removed from the schema. ` +
243
+ 'Values already stored under those field paths will remain in the database.',
244
+ confirmLabel: 'Remove',
245
+ variant: 'destructive'
246
+ })
247
+ if (!ok) return
248
+ setDraft((prev) => {
249
+ const next = cloneSchema(prev)
250
+ next.sections.splice(idx, 1)
251
+ return next
252
+ })
253
+ setActiveSectionIdx(0)
254
+ }
255
+
256
+ const addGroup = () => {
257
+ updateSection(activeSectionIdx, { groups: [...(activeSection.groups || []), blankGroup()] })
258
+ }
259
+
260
+ const updateGroup = (gi, next) => {
261
+ const groups = [...(activeSection.groups || [])]
262
+ groups[gi] = next
263
+ updateSection(activeSectionIdx, { groups })
264
+ }
265
+
266
+ const removeGroup = (gi) => {
267
+ const groups = [...(activeSection.groups || [])]
268
+ groups.splice(gi, 1)
269
+ updateSection(activeSectionIdx, { groups })
270
+ }
271
+
272
+ const handleSave = async () => {
273
+ const localMsg = validateLocal(draft)
274
+ if (localMsg) {
275
+ setLocalError(localMsg)
276
+ return
277
+ }
278
+ setLocalError(null)
279
+ await onSave(draft)
280
+ }
281
+
282
+ const combinedError = localError || error
283
+ const displayedSections = useMemo(() => draft.sections, [draft.sections])
284
+
285
+ const P = palette || PALETTE
286
+
287
+ const card = {
288
+ background: P.surface,
289
+ border: `1px solid ${P.border}`,
290
+ borderRadius: RADIUS.lg,
291
+ boxShadow: SHADOW.xs
292
+ }
293
+
294
+ return (
295
+ <View>
296
+ {confirmDialog}
297
+ {combinedError && (
298
+ <Well marginBottom="size-200" UNSAFE_style={{ borderColor: P.danger }}>
299
+ <Text UNSAFE_style={{ color: P.danger }}>{combinedError}</Text>
300
+ </Well>
301
+ )}
302
+
303
+ {/* Sticky save bar at the top, just below the hero card.
304
+ Uses the runtime-measured hero height shared via the
305
+ --sc-hero-h CSS variable set by SystemConfig. */}
306
+ <div
307
+ style={{
308
+ position: 'sticky',
309
+ top: 'calc(64px + var(--sc-hero-h, 160px))',
310
+ marginBottom: 16,
311
+ padding: '12px 20px',
312
+ background: P.surface,
313
+ border: `1px solid ${P.border}`,
314
+ borderRadius: RADIUS.xl,
315
+ boxShadow: SHADOW.floating,
316
+ zIndex: 10
317
+ }}
318
+ >
319
+ <Flex gap="size-100" justifyContent="space-between" alignItems="center">
320
+ <div style={{ fontSize: 12, color: P.textMuted }}>
321
+ {displayedSections.length} section{displayedSections.length === 1 ? '' : 's'} ·
322
+ {' '}{displayedSections.reduce((n, s) => n + (s.groups || []).length, 0)} groups ·
323
+ {' '}{displayedSections.reduce((n, s) => n + (s.groups || []).reduce((m, g) => m + (g.fields || []).length, 0), 0)} fields
324
+ </div>
325
+ <Flex gap="size-100">
326
+ <Button variant="secondary" onPress={onCancel} isDisabled={saving}>Cancel</Button>
327
+ <Button variant="cta" onPress={handleSave} isDisabled={saving}>
328
+ {saving ? 'Saving…' : 'Save schema'}
329
+ </Button>
330
+ </Flex>
331
+ </Flex>
332
+ </div>
333
+
334
+ <div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
335
+ {/* Section sidebar — pill-track styling matches the top AppSectionNav. */}
336
+ <aside
337
+ role="tablist"
338
+ aria-label="Sections"
339
+ style={{
340
+ width: 260,
341
+ flexShrink: 0,
342
+ background: P.surfaceMuted,
343
+ border: `1px solid ${P.border}`,
344
+ borderRadius: RADIUS.xxl,
345
+ boxShadow: SHADOW.inset,
346
+ padding: 6,
347
+ position: 'sticky',
348
+ // Sit below AppSectionNav (64) + hero card (measured) + save bar (64) + gap
349
+ top: 'calc(64px + var(--sc-hero-h, 160px) + 80px)',
350
+ alignSelf: 'flex-start',
351
+ maxHeight: 'calc(100vh - 64px - var(--sc-hero-h, 160px) - 96px)',
352
+ overflowY: 'auto',
353
+ display: 'flex',
354
+ flexDirection: 'column',
355
+ gap: 4
356
+ }}
357
+ >
358
+ <div style={{
359
+ fontSize: 10,
360
+ fontWeight: 700,
361
+ letterSpacing: 0.8,
362
+ textTransform: 'uppercase',
363
+ color: P.textMuted,
364
+ padding: '6px 14px 4px'
365
+ }}>Sections</div>
366
+ {displayedSections.map((s, idx) => {
367
+ const active = idx === activeSectionIdx
368
+ return (
369
+ <div key={idx} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
370
+ <button
371
+ type="button"
372
+ role="tab"
373
+ aria-selected={active}
374
+ onClick={() => setActiveSectionIdx(idx)}
375
+ style={{
376
+ flex: 1,
377
+ display: 'flex',
378
+ alignItems: 'center',
379
+ padding: '10px 14px',
380
+ border: 0,
381
+ borderRadius: RADIUS.pill,
382
+ background: active ? P.surface : 'transparent',
383
+ cursor: active ? 'default' : 'pointer',
384
+ font: 'inherit',
385
+ color: active ? P.accent : PALETTE.neutralText,
386
+ fontWeight: active ? 700 : 600,
387
+ textAlign: 'left',
388
+ fontSize: 13,
389
+ overflow: 'hidden',
390
+ textOverflow: 'ellipsis',
391
+ whiteSpace: 'nowrap',
392
+ boxShadow: active ? SHADOW.pill : 'none',
393
+ transition: 'background 140ms ease, color 140ms ease, box-shadow 140ms ease'
394
+ }}
395
+ onMouseOver={(e) => { if (!active) { e.currentTarget.style.background = PALETTE.surface; e.currentTarget.style.color = PALETTE.text } }}
396
+ onMouseOut={(e) => { if (!active) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = PALETTE.neutralText } }}
397
+ >
398
+ {s.label || s.id || `(section ${idx + 1})`}
399
+ </button>
400
+ <ActionButton isQuiet onPress={() => removeSection(idx)} aria-label="Remove">✕</ActionButton>
401
+ </div>
402
+ )
403
+ })}
404
+ <div style={{ padding: '6px 6px 4px' }}>
405
+ <Button variant="secondary" onPress={addSection} UNSAFE_style={{ width: '100%', borderRadius: RADIUS.pill }}>
406
+ + Add section
407
+ </Button>
408
+ </div>
409
+ </aside>
410
+
411
+ {/* Active section editor */}
412
+ <div style={{ flex: 1, minWidth: 0 }}>
413
+ {!activeSection ? (
414
+ <div style={{ ...card, padding: 40, textAlign: 'center' }}>
415
+ <Heading level={3} marginTop={0}>No section selected</Heading>
416
+ <Text UNSAFE_style={{ color: P.textMuted }}>
417
+ Add a section on the left to begin building your configuration schema.
418
+ </Text>
419
+ </div>
420
+ ) : (
421
+ <>
422
+ <div style={{ ...card, padding: 20, marginBottom: 16 }}>
423
+ <div style={{
424
+ fontSize: 11, fontWeight: 700, letterSpacing: 0.8,
425
+ textTransform: 'uppercase', color: P.textMuted, marginBottom: 12
426
+ }}>Section properties</div>
427
+ <Flex gap="size-200" alignItems="end" wrap>
428
+ <TextField
429
+ label="Section ID"
430
+ value={activeSection.id}
431
+ onChange={(v) => updateSection(activeSectionIdx, { id: v })}
432
+ width="size-2400"
433
+ />
434
+ <TextField
435
+ label="Section Label"
436
+ value={activeSection.label}
437
+ onChange={(v) => updateSection(activeSectionIdx, { label: v })}
438
+ width="size-3600"
439
+ />
440
+ </Flex>
441
+ </div>
442
+
443
+ {(activeSection.groups || []).map((g, gi) => (
444
+ <GroupEditor
445
+ key={gi}
446
+ group={g}
447
+ onChange={(next) => updateGroup(gi, next)}
448
+ onRemove={() => removeGroup(gi)}
449
+ />
450
+ ))}
451
+ <Button variant="secondary" onPress={addGroup}>+ Add group</Button>
452
+ </>
453
+ )}
454
+ </div>
455
+ </div>
456
+
457
+ </View>
458
+ )
459
+ }