From d392973c07b075cf85e9d20a6b4e95cbdcadc823 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Tue, 7 Apr 2026 22:44:19 -0700 Subject: [PATCH 01/23] fix(config): rename 'Overrides' to 'Localizations' in RSVP locale section heading Co-Authored-By: Claude Opus 4.6 --- .../ConfigManagement/ConfigManagement.tsx | 1597 +++++++++++++++++ 1 file changed, 1597 insertions(+) create mode 100644 web-src/src/pages/ConfigManagement/ConfigManagement.tsx diff --git a/web-src/src/pages/ConfigManagement/ConfigManagement.tsx b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx new file mode 100644 index 0000000..2da2c74 --- /dev/null +++ b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx @@ -0,0 +1,1597 @@ +/** + * ConfigManagement — Admin page for managing scope-level configs + * (RSVP form fields, locale mappings, custom attributes). + * + * Layout: + * 1. Scope selector (ComboBox) + scope type badge + * 2. Tab switcher: RSVP Fields | Locale Mapping | Custom Attributes + * 3. Tab-specific content with tables, expandable rows, and CRUD dialogs + */ + +import React, { useState, useCallback, useMemo, useEffect } from 'react' +import { + Badge, + Button, + ButtonGroup, + TextField, + Picker, + PickerItem, + ComboBox, + ComboBoxItem, + Text, + DialogTrigger, + Dialog, + Content, + Heading, + Switch as SpectrumSwitch, + ActionButton, + AlertDialog, + Divider, + TabList, + TabPanel, + Tabs, + Tab, + Checkbox, +} from '@react-spectrum/s2' +import { style } from '@react-spectrum/s2/style' with { type: 'macro' } +import EditIcon from '@react-spectrum/s2/icons/Edit' +import Add from '@react-spectrum/s2/icons/Add' +import RemoveCircle from '@react-spectrum/s2/icons/RemoveCircle' +import RotateCCW from '@react-spectrum/s2/icons/RotateCCW' +import GearSettingIllustration from '@react-spectrum/s2/illustrations/linear/GearSetting' +import { useApi } from '../../contexts/ApiContext' +import { useToast, useGroup } from '../../contexts' +import { IMS } from '../../types' +import type { RBACApiScope, ScopeType } from '../../types/rbacApi' +import type { + ScopeConfig, + RsvpScopeConfig, + LocalesScopeConfig, + RsvpFormField, + RsvpFormFieldLocaleOverride, + EnabledAttributeRef, + CustomAttribute, + CustomAttributeValue, + CustomAttributeInputType, + RsvpFieldType, + RsvpDisplayAs, +} from '../../types/configApi' +import { ResourceDashboardLayout, BlurredLoadingOverlay } from '../../components/shared' +import { useHasPermission } from '../../hooks/useHasPermission' +import { SUPPORTED_SPEAKER_LOCALES, SPEAKER_LOCALE_LABELS } from '../../config/localeMapping' + +interface ConfigManagementProps { + ims: IMS +} + +const SCOPE_TYPE_VARIANTS: Record = { + platform: 'positive', + org: 'informative', + team: 'neutral', +} + +const RSVP_FIELD_TYPES: { key: RsvpFieldType; label: string }[] = [ + { key: 'text', label: 'Text' }, + { key: 'email', label: 'Email' }, + { key: 'phone', label: 'Phone' }, + { key: 'select', label: 'Select' }, + { key: 'multi-select', label: 'Multi-Select' }, +] + +const RSVP_DISPLAY_AS_OPTIONS: { key: RsvpDisplayAs; label: string }[] = [ + { key: 'dropdown', label: 'Dropdown' }, + { key: 'radio', label: 'Radio' }, + { key: 'checkbox', label: 'Checkbox' }, +] + +const ATTRIBUTE_INPUT_TYPES: { key: CustomAttributeInputType; label: string }[] = [ + { key: 'text', label: 'Text' }, + { key: 'boolean', label: 'Boolean' }, + { key: 'single-select', label: 'Single Select' }, + { key: 'multi-select', label: 'Multi Select' }, +] + +function createEmptyRsvpField(): RsvpFormField { + return { + field: '', + label: '', + placeholder: '', + type: 'text', + required: false, + options: [], + rules: '', + default: '', + displayAs: '', + } +} + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const ConfigManagement: React.FC = () => { + const apiService = useApi() + const toast = useToast() + const { groups: userMemberGroups } = useGroup() + + // Permissions + const canWriteConfig = useHasPermission('config', 'write') + const canDeleteConfig = useHasPermission('config', 'delete') + + // ============================================================================ + // SCOPE STATE + // ============================================================================ + + const [scopes, setScopes] = useState([]) + const [selectedScopeId, setSelectedScopeId] = useState(null) + const [scopeFilterText, setScopeFilterText] = useState('') + const [myScopesOnly, setMyScopesOnly] = useState(false) + const [isLoadingScopes, setIsLoadingScopes] = useState(true) + + // ============================================================================ + // CONFIG STATE + // ============================================================================ + + const [configs, setConfigs] = useState([]) + const [isLoadingConfigs, setIsLoadingConfigs] = useState(false) + const [customAttributes, setCustomAttributes] = useState([]) + const [isLoadingAttributes, setIsLoadingAttributes] = useState(false) + const [activeTab, setActiveTab] = useState('rsvp') + + // ============================================================================ + // RSVP DIALOG STATE + // ============================================================================ + + const [isRsvpFormOpen, setIsRsvpFormOpen] = useState(false) + const [editingRsvpConfig, setEditingRsvpConfig] = useState(null) + const [rsvpFormFields, setRsvpFormFields] = useState([]) + const [rsvpEnabledAttributes, setRsvpEnabledAttributes] = useState([]) + const [rsvpLocalizations, setRsvpLocalizations] = useState>({}) + const [rsvpLocaleEditing, setRsvpLocaleEditing] = useState(null) + const [rsvpConfigToDelete, setRsvpConfigToDelete] = useState(null) + + // Expandable state for RSVP fields table + const [expandedFieldKeys, setExpandedFieldKeys] = useState>(new Set()) + + // ============================================================================ + // LOCALES DIALOG STATE + // ============================================================================ + + const [isLocalesFormOpen, setIsLocalesFormOpen] = useState(false) + const [editingLocalesConfig, setEditingLocalesConfig] = useState(null) + const [localeEntries, setLocaleEntries] = useState>([]) + const [localesToDelete, setLocalesToDelete] = useState(null) + + // ============================================================================ + // CUSTOM ATTRIBUTE DIALOG STATE + // ============================================================================ + + const [isAttrFormOpen, setIsAttrFormOpen] = useState(false) + const [editingAttr, setEditingAttr] = useState(null) + const [attrFormName, setAttrFormName] = useState('') + const [attrFormInputType, setAttrFormInputType] = useState('text') + const [attrFormValues, setAttrFormValues] = useState([]) + const [attrToDelete, setAttrToDelete] = useState(null) + + // Expandable state for attributes table + const [expandedAttrKeys, setExpandedAttrKeys] = useState>(new Set()) + + // Action state + const [isSaving, setIsSaving] = useState(false) + + // ============================================================================ + // DERIVED DATA + // ============================================================================ + + const selectedScope = useMemo( + () => scopes.find(s => s.scopeId === selectedScopeId) || null, + [scopes, selectedScopeId] + ) + + const scopeIdsImMemberOf = useMemo(() => { + const ids = new Set() + for (const g of userMemberGroups) { + if (g.scopeId) ids.add(g.scopeId) + } + return ids + }, [userMemberGroups]) + + // Filter to org/team scopes only (configs can't be at platform level) + const scopesForPicker = useMemo(() => { + let filtered = scopes.filter(s => s.type === 'org' || s.type === 'team') + if (myScopesOnly) filtered = filtered.filter(s => scopeIdsImMemberOf.has(s.scopeId)) + return filtered + }, [scopes, myScopesOnly, scopeIdsImMemberOf]) + + const filteredScopes = useMemo(() => { + const items = scopesForPicker.map(s => ({ id: s.scopeId, name: s.name, type: s.type })) + if (!scopeFilterText) return items + const lower = scopeFilterText.toLowerCase() + return items.filter(s => s.name.toLowerCase().includes(lower) || s.type.toLowerCase().includes(lower)) + }, [scopesForPicker, scopeFilterText]) + + const rsvpConfig = useMemo( + () => configs.find((c): c is RsvpScopeConfig => c.type === 'rsvp') || null, + [configs] + ) + const localesConfig = useMemo( + () => configs.find((c): c is LocalesScopeConfig => c.type === 'locales') || null, + [configs] + ) + // Available locales for RSVP localization (from sibling locales config or fallback) + const availableLocales = useMemo(() => { + if (localesConfig) { + return Object.entries(localesConfig.localeNames).map(([code, name]) => ({ code, name })) + } + return SUPPORTED_SPEAKER_LOCALES.map(code => ({ + code, + name: SPEAKER_LOCALE_LABELS[code] || code, + })) + }, [localesConfig]) + + // ============================================================================ + // DATA LOADING + // ============================================================================ + + const loadScopes = useCallback(async () => { + setIsLoadingScopes(true) + try { + const result = await apiService.getScopes() + if (!('error' in result)) setScopes(result) + } catch { + // Handled by consumers + } finally { + setIsLoadingScopes(false) + } + }, [apiService]) + + const loadConfigs = useCallback(async () => { + if (!selectedScopeId) { + setConfigs([]) + return + } + setIsLoadingConfigs(true) + try { + const result = await apiService.getConfigsForScope(selectedScopeId) + if (!('error' in result)) setConfigs(result) + } catch { + // Errors handled silently — consumer shows empty state + } finally { + setIsLoadingConfigs(false) + } + }, [apiService, selectedScopeId]) + + const loadCustomAttributes = useCallback(async () => { + if (!selectedScopeId) { + setCustomAttributes([]) + return + } + setIsLoadingAttributes(true) + try { + const result = await apiService.getCustomAttributesForScope(selectedScopeId) + if (!('error' in result)) setCustomAttributes(result) + } catch { + // Errors handled silently — consumer shows empty state + } finally { + setIsLoadingAttributes(false) + } + }, [apiService, selectedScopeId]) + + useEffect(() => { loadScopes() }, [loadScopes]) + useEffect(() => { loadConfigs() }, [loadConfigs]) + useEffect(() => { loadCustomAttributes() }, [loadCustomAttributes]) + + // Clear state on scope change + useEffect(() => { + setExpandedFieldKeys(new Set()) + setExpandedAttrKeys(new Set()) + }, [selectedScopeId]) + + // Drop scope selection if it falls outside the picker pool + useEffect(() => { + if (!selectedScopeId) return + if (!scopesForPicker.some(s => s.scopeId === selectedScopeId)) { + setSelectedScopeId(null) + } + }, [selectedScopeId, scopesForPicker]) + + // ============================================================================ + // RSVP CONFIG CRUD + // ============================================================================ + + const openRsvpCreate = useCallback(() => { + setEditingRsvpConfig(null) + setRsvpFormFields([createEmptyRsvpField()]) + setRsvpEnabledAttributes([]) + setRsvpLocalizations({}) + setRsvpLocaleEditing(null) + setIsRsvpFormOpen(true) + }, []) + + const openRsvpEdit = useCallback((config: RsvpScopeConfig) => { + setEditingRsvpConfig(config) + setRsvpFormFields([...config.rsvpFormFields]) + setRsvpEnabledAttributes([...(config.enabledAttributes || [])]) + setRsvpLocalizations(config.localizations ? JSON.parse(JSON.stringify(config.localizations)) : {}) + setRsvpLocaleEditing(null) + setIsRsvpFormOpen(true) + }, []) + + const handleSaveRsvpConfig = useCallback(async () => { + if (!selectedScopeId) return + const validFields = rsvpFormFields.filter(f => f.field.trim() && f.label.trim()) + if (validFields.length === 0) { + toast.error('At least one field with a name and label is required') + return + } + + setIsSaving(true) + try { + if (editingRsvpConfig) { + const result = await apiService.updateConfig(selectedScopeId, editingRsvpConfig.configId, { + ...editingRsvpConfig, + rsvpFormFields: validFields, + enabledAttributes: rsvpEnabledAttributes, + localizations: rsvpLocalizations, + }) + if ('error' in result) { + const status = (result as { status: number }).status + toast.error(status === 409 + ? 'This config was modified by someone else. Refresh and try again.' + : 'Failed to update RSVP config') + return + } + toast.success('RSVP config updated') + } else { + const result = await apiService.createConfig(selectedScopeId, { + type: 'rsvp', + rsvpFormFields: validFields, + enabledAttributes: rsvpEnabledAttributes, + localizations: rsvpLocalizations, + }) + if ('error' in result) { + const status = (result as { status: number }).status + toast.error(status === 409 + ? 'An RSVP config already exists for this scope' + : 'Failed to create RSVP config') + return + } + toast.success('RSVP config created') + } + setIsRsvpFormOpen(false) + setIsSaving(false) + await loadConfigs() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save RSVP config') + } finally { + setIsSaving(false) + } + }, [selectedScopeId, rsvpFormFields, rsvpEnabledAttributes, rsvpLocalizations, editingRsvpConfig, apiService, toast, loadConfigs]) + + const handleDeleteConfig = useCallback(async (config: ScopeConfig) => { + if (!selectedScopeId) return + setIsSaving(true) + try { + const result = await apiService.deleteConfig(selectedScopeId, config.configId) + if ('error' in result) { + toast.error('Failed to delete config') + return + } + toast.success('Config deleted') + setRsvpConfigToDelete(null) + setLocalesToDelete(null) + setIsSaving(false) + await loadConfigs() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete config') + } finally { + setIsSaving(false) + } + }, [apiService, selectedScopeId, toast, loadConfigs]) + + // ============================================================================ + // LOCALES CONFIG CRUD + // ============================================================================ + + const openLocalesCreate = useCallback(() => { + setEditingLocalesConfig(null) + setLocaleEntries([{ code: 'en-US', name: 'English, United States', urlCode: '' }]) + setIsLocalesFormOpen(true) + }, []) + + const openLocalesEdit = useCallback((config: LocalesScopeConfig) => { + setEditingLocalesConfig(config) + const entries = Object.entries(config.localeNames).map(([code, name]) => ({ + code, + name, + urlCode: config.localeUrlCodes[code] || '', + })) + setLocaleEntries(entries.length > 0 ? entries : [{ code: '', name: '', urlCode: '' }]) + setIsLocalesFormOpen(true) + }, []) + + const handleSaveLocalesConfig = useCallback(async () => { + if (!selectedScopeId) return + const validEntries = localeEntries.filter(e => e.code.trim() && e.name.trim()) + if (validEntries.length === 0) { + toast.error('At least one locale entry is required') + return + } + + const localeNames: Record = {} + const localeUrlCodes: Record = {} + for (const entry of validEntries) { + localeNames[entry.code.trim()] = entry.name.trim() + localeUrlCodes[entry.code.trim()] = entry.urlCode.trim() + } + + setIsSaving(true) + try { + if (editingLocalesConfig) { + const result = await apiService.updateConfig(selectedScopeId, editingLocalesConfig.configId, { + ...editingLocalesConfig, + localeNames, + localeUrlCodes, + }) + if ('error' in result) { + const status = (result as { status: number }).status + toast.error(status === 409 + ? 'This config was modified by someone else. Refresh and try again.' + : 'Failed to update locales config') + return + } + toast.success('Locales config updated') + } else { + const result = await apiService.createConfig(selectedScopeId, { + type: 'locales', + localeNames, + localeUrlCodes, + }) + if ('error' in result) { + const status = (result as { status: number }).status + toast.error(status === 409 + ? 'A locales config already exists for this scope' + : 'Failed to create locales config') + return + } + toast.success('Locales config created') + } + setIsLocalesFormOpen(false) + setIsSaving(false) + await loadConfigs() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save locales config') + } finally { + setIsSaving(false) + } + }, [selectedScopeId, localeEntries, editingLocalesConfig, apiService, toast, loadConfigs]) + + // ============================================================================ + // CUSTOM ATTRIBUTE CRUD + // ============================================================================ + + const openAttrCreate = useCallback(() => { + setEditingAttr(null) + setAttrFormName('') + setAttrFormInputType('text') + setAttrFormValues([]) + setIsAttrFormOpen(true) + }, []) + + const openAttrEdit = useCallback((attr: CustomAttribute) => { + setEditingAttr(attr) + setAttrFormName(attr.name) + setAttrFormInputType(attr.inputType) + setAttrFormValues([...attr.values]) + setIsAttrFormOpen(true) + }, []) + + const handleSaveAttr = useCallback(async () => { + if (!selectedScopeId || !attrFormName.trim()) { + toast.error('Name is required') + return + } + + setIsSaving(true) + try { + if (editingAttr) { + const result = await apiService.updateCustomAttribute(selectedScopeId, editingAttr.attributeId, { + attributeId: editingAttr.attributeId, + name: attrFormName.trim(), + inputType: attrFormInputType, + values: attrFormValues, + }) + if ('error' in result) { + const status = (result as { status: number }).status + toast.error(status === 409 + ? 'This attribute was modified by someone else. Refresh and try again.' + : 'Failed to update custom attribute') + return + } + toast.success('Custom attribute updated') + } else { + const result = await apiService.createCustomAttribute(selectedScopeId, { + name: attrFormName.trim(), + inputType: attrFormInputType, + values: attrFormValues.filter(v => v.value.trim()).map(v => ({ value: v.value.trim() })), + }) + if ('error' in result) { + toast.error('Failed to create custom attribute') + return + } + toast.success('Custom attribute created') + } + setIsAttrFormOpen(false) + setIsSaving(false) + await loadCustomAttributes() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save custom attribute') + } finally { + setIsSaving(false) + } + }, [selectedScopeId, attrFormName, attrFormInputType, attrFormValues, editingAttr, apiService, toast, loadCustomAttributes]) + + const handleDeleteAttr = useCallback(async (attr: CustomAttribute) => { + if (!selectedScopeId) return + setIsSaving(true) + try { + const result = await apiService.deleteCustomAttribute(selectedScopeId, attr.attributeId) + if ('error' in result) { + toast.error('Failed to delete custom attribute') + return + } + toast.success('Custom attribute deleted') + setAttrToDelete(null) + setIsSaving(false) + await loadCustomAttributes() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete custom attribute') + } finally { + setIsSaving(false) + } + }, [apiService, selectedScopeId, toast, loadCustomAttributes]) + + // ============================================================================ + // RSVP FIELD HELPERS + // ============================================================================ + + const handleToggleFieldExpand = useCallback((key: string) => { + setExpandedFieldKeys(prev => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }, []) + + const handleToggleAttrExpand = useCallback((key: string) => { + setExpandedAttrKeys(prev => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }, []) + + // ============================================================================ + // RSVP LOCALIZATION HELPERS + // ============================================================================ + + const getLocaleOverrides = useCallback((locale: string): RsvpFormFieldLocaleOverride[] => { + return rsvpLocalizations[locale]?.rsvpFormFields || [] + }, [rsvpLocalizations]) + + const setLocaleOverrideField = useCallback((locale: string, fieldName: string, prop: 'label' | 'placeholder', value: string) => { + setRsvpLocalizations(prev => { + const copy = JSON.parse(JSON.stringify(prev)) + if (!copy[locale]) copy[locale] = { rsvpFormFields: [] } + const fields = copy[locale].rsvpFormFields as RsvpFormFieldLocaleOverride[] + const existing = fields.find(f => f.field === fieldName) + if (existing) { + existing[prop] = value || undefined + } else { + fields.push({ field: fieldName, [prop]: value || undefined }) + } + // Remove entries with no overrides + copy[locale].rsvpFormFields = fields.filter( + (f: RsvpFormFieldLocaleOverride) => f.label || f.placeholder || (f.options && f.options.length > 0) + ) + if (copy[locale].rsvpFormFields.length === 0) delete copy[locale] + return copy + }) + }, []) + + const setLocaleOverrideOptions = useCallback((locale: string, fieldName: string, optionsStr: string) => { + setRsvpLocalizations(prev => { + const copy = JSON.parse(JSON.stringify(prev)) + if (!copy[locale]) copy[locale] = { rsvpFormFields: [] } + const fields = copy[locale].rsvpFormFields as RsvpFormFieldLocaleOverride[] + const existing = fields.find(f => f.field === fieldName) + const options = optionsStr ? optionsStr.split('\n').map(o => o.trim()).filter(Boolean) : undefined + if (existing) { + existing.options = options + } else { + fields.push({ field: fieldName, options }) + } + copy[locale].rsvpFormFields = fields.filter( + (f: RsvpFormFieldLocaleOverride) => f.label || f.placeholder || (f.options && f.options.length > 0) + ) + if (copy[locale].rsvpFormFields.length === 0) delete copy[locale] + return copy + }) + }, []) + + // ============================================================================ + // RSVP TABLE COLUMNS (for display) + // ============================================================================ + + const rsvpFieldsForTable = useMemo(() => { + if (!rsvpConfig) return [] + return rsvpConfig.rsvpFormFields.map((f, i) => ({ + ...f, + _key: `${f.field}-${i}`, + })) + }, [rsvpConfig]) + + const rsvpFieldColumns = useMemo(() => [ + { key: 'field', name: 'FIELD NAME', width: 160, sortable: true }, + { key: 'label', name: 'LABEL', width: 160, sortable: true }, + { + key: 'type', + name: 'TYPE', + width: 120, + sortable: true, + render: (item: RsvpFormField & { _key: string }) => ( + {item.type} + ), + }, + { + key: 'required', + name: 'REQUIRED', + width: 100, + sortable: false, + render: (item: RsvpFormField & { _key: string }) => ( + {item.required ? 'Yes' : 'No'} + ), + }, + { + key: 'options', + name: 'OPTIONS', + width: 100, + sortable: false, + render: (item: RsvpFormField & { _key: string }) => ( + {item.options.length > 0 ? `${item.options.length} options` : '-'} + ), + }, + { + key: 'displayAs', + name: 'DISPLAY AS', + width: 120, + sortable: false, + render: (item: RsvpFormField & { _key: string }) => ( + {item.displayAs || '-'} + ), + }, + ], []) + + const renderRsvpExpandedContent = useCallback((item: RsvpFormField & { _key: string }) => { + const locales = Object.keys(rsvpConfig?.localizations || {}) + return ( +
+ {item.options.length > 0 && ( +
+ Options: +
+ {item.options.map((opt, i) => ( + {opt} + ))} +
+
+ )} + {item.rules && ( +
+ Rules: + {item.rules} +
+ )} + {item.default && ( +
+ Default: + {item.default} +
+ )} + {locales.length > 0 && ( +
+ Localizations: +
+ {locales.map(locale => { + const overrides = rsvpConfig?.localizations[locale]?.rsvpFormFields || [] + const override = overrides.find(o => o.field === item.field) + if (!override) return null + return ( +
+ {locale} + {override.label && Label: {override.label}} + {override.placeholder && Placeholder: {override.placeholder}} + {override.options && Options: {override.options.join(', ')}} +
+ ) + })} +
+
+ )} + {!item.options.length && !item.rules && !item.default && locales.length === 0 && ( + + No additional details + + )} +
+ ) + }, [rsvpConfig]) + + // ============================================================================ + // CUSTOM ATTRIBUTES TABLE + // ============================================================================ + + const attrColumns = useMemo(() => [ + { key: 'name', name: 'NAME', width: 200, sortable: true }, + { + key: 'inputType', + name: 'INPUT TYPE', + width: 140, + sortable: true, + render: (item: CustomAttribute) => ( + {item.inputType} + ), + }, + { + key: 'values', + name: 'VALUES', + width: 100, + sortable: false, + render: (item: CustomAttribute) => ( + {item.values.length > 0 ? `${item.values.length} values` : '-'} + ), + }, + { + key: 'scopeId', + name: 'SCOPE', + width: 120, + sortable: false, + render: (item: CustomAttribute) => { + const scope = scopes.find(s => s.scopeId === item.scopeId) + return scope ? ( + {scope.name} + ) : ( + + {item.scopeId.substring(0, 8)}... + + ) + }, + }, + { + key: 'actions', + name: 'ACTIONS', + width: 100, + sortable: false, + render: (item: CustomAttribute) => { + const isOwnScope = item.scopeId === selectedScopeId + return ( +
+ {canWriteConfig && isOwnScope && ( + openAttrEdit(item)}> + + + )} + {canDeleteConfig && isOwnScope && ( + setAttrToDelete(item)}> + + + )} +
+ ) + }, + }, + ], [scopes, selectedScopeId, canWriteConfig, canDeleteConfig, openAttrEdit]) + + const renderAttrExpandedContent = useCallback((item: CustomAttribute) => { + if (item.values.length === 0) { + return ( + + No values defined (free-form input) + + ) + } + return ( +
+ {item.values + .sort((a, b) => a.displayOrder - b.displayOrder) + .map((v, i) => ( + {v.value} + ))} +
+ ) + }, []) + + // ============================================================================ + // LOADING OVERLAY + // ============================================================================ + + const { loadingOverlayVisible, savingOverlayVisible } = useMemo(() => { + const isBlockingDialogOpen = + isRsvpFormOpen || isLocalesFormOpen || isAttrFormOpen || + rsvpConfigToDelete != null || localesToDelete != null || attrToDelete != null + return { + loadingOverlayVisible: (isLoadingScopes || isLoadingConfigs || isLoadingAttributes) && !isSaving, + savingOverlayVisible: isSaving && !isBlockingDialogOpen, + } + }, [ + isRsvpFormOpen, isLocalesFormOpen, isAttrFormOpen, + rsvpConfigToDelete, localesToDelete, attrToDelete, + isLoadingScopes, isLoadingConfigs, isLoadingAttributes, + isSaving, + ]) + + // ============================================================================ + // RENDER + // ============================================================================ + + return ( +
+
+
+ Configuration Management + + Show my scopes only + +
+ + {/* Scope selector */} +
+
+
+ setSelectedScopeId(key as string | null)} + onInputChange={setScopeFilterText} + defaultItems={filteredScopes} + styles={style({ width: 480 })} + menuTrigger="input" + menuWidth={480} + allowsCustomValue={false} + > + {(item) => ( + + {item.name} + {item.type} + + )} + + + {selectedScope && ( +
+ + {selectedScope.type} + + +
+ )} +
+
+
+ + + + {/* Tab content */} + {selectedScopeId ? ( + setActiveTab(key as string)}> + + RSVP Fields + Locale Mapping + Custom Attributes + + + {/* ── RSVP Fields Tab ── */} + +
+ {rsvpConfig ? ( + item._key} + createButton={canWriteConfig ? ( + + + {canDeleteConfig && ( + + )} + + ) : undefined} + onRefresh={loadConfigs} + emptyStateTitle="No Fields" + emptyStateDescription="This RSVP config has no form fields" + searchPlaceholder="Search fields..." + searchKeys={['field', 'label', 'type']} + renderExpandedContent={renderRsvpExpandedContent} + expandedKeys={expandedFieldKeys} + onToggleExpand={handleToggleFieldExpand} + /> + ) : ( +
+ + No RSVP config for this scope. + + {canWriteConfig && ( +
+ +
+ )} +
+ )} +
+
+ + {/* ── Locale Mapping Tab ── */} + +
+ {localesConfig ? ( +
+
+ Locale Mapping + + {canWriteConfig && ( + + )} + {canDeleteConfig && ( + + )} + +
+
+ + + + + + + + + + {Object.entries(localesConfig.localeNames).map(([code, name]) => ( + + + + + + ))} + +
Locale CodeDisplay NameURL Code
+ {code} + + {name} + + {localesConfig.localeUrlCodes[code] || '(default)'} +
+
+
+ ) : ( +
+ + No locales config for this scope. + + {canWriteConfig && ( +
+ +
+ )} +
+ )} +
+
+ + {/* ── Custom Attributes Tab ── */} + +
+ item.attributeId} + createButton={canWriteConfig ? ( + + ) : undefined} + onRefresh={loadCustomAttributes} + emptyStateIllustration={} + emptyStateTitle="No Custom Attributes" + emptyStateDescription="Create custom attributes to add additional fields to events" + searchPlaceholder="Search attributes..." + searchKeys={['name', 'inputType']} + renderExpandedContent={renderAttrExpandedContent} + expandedKeys={expandedAttrKeys} + onToggleExpand={handleToggleAttrExpand} + /> +
+
+
+ ) : ( +
+ + Select a scope above to manage its configurations. + +
+ )} +
+ + {/* ══════════════════════════════════════════════════════════════════════ + DIALOGS + ══════════════════════════════════════════════════════════════════════ */} + + {/* RSVP Config Create/Edit Dialog */} + +
+ + {({close}) => ( + <> + {editingRsvpConfig ? 'Edit RSVP Config' : 'Create RSVP Config'} + +
+ {/* Fields editor */} +
+
+ Form Fields + +
+
+ {rsvpFormFields.map((field, index) => ( +
+
+ Field {index + 1} + setRsvpFormFields(prev => prev.filter((_, i) => i !== index))} + isDisabled={rsvpFormFields.length <= 1} + > + + +
+
+ setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], field: v } + return copy + })} + isRequired + /> + setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], label: v } + return copy + })} + isRequired + /> + setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], placeholder: v } + return copy + })} + /> + setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], type: key as RsvpFieldType } + return copy + })} + > + {RSVP_FIELD_TYPES.map(t => ( + {t.label} + ))} + + {(field.type === 'select' || field.type === 'multi-select') && ( + setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], displayAs: key as RsvpDisplayAs } + return copy + })} + > + {RSVP_DISPLAY_AS_OPTIONS.map(o => ( + {o.label} + ))} + + )} +
+
+ setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], required: v } + return copy + })} + > + Required + +
+ {(field.type === 'select' || field.type === 'multi-select') && ( +
+ setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { + ...copy[index], + options: v.split('\n').map(o => o.trim()).filter(Boolean), + } + return copy + })} + styles={style({ width: '[100%]' })} + /> +
+ )} +
+ ))} +
+
+ + {/* Enabled Attributes */} + {customAttributes.length > 0 && ( +
+ Enabled Custom Attributes +
+ {customAttributes.map(attr => { + const isEnabled = rsvpEnabledAttributes.some(ea => ea.attributeId === attr.attributeId) + return ( + { + if (checked) { + setRsvpEnabledAttributes(prev => [...prev, { attributeId: attr.attributeId, name: attr.name }]) + } else { + setRsvpEnabledAttributes(prev => prev.filter(ea => ea.attributeId !== attr.attributeId)) + } + }} + > + {attr.name} ({attr.inputType}) + + ) + })} +
+
+ )} + + {/* Localizations */} +
+ Localizations +
+ setRsvpLocaleEditing(key as string | null)} + styles={style({ width: 280 })} + > + {availableLocales.map(l => ( + + {l.name} ({l.code}) + + ))} + +
+ {rsvpLocaleEditing && ( +
+ + Localizations for {rsvpLocaleEditing} + +
+ {rsvpFormFields.filter(f => f.field.trim()).map((field, i) => { + const overrides = getLocaleOverrides(rsvpLocaleEditing!) + const override = overrides.find(o => o.field === field.field) + return ( +
+ + {field.field}: + + setLocaleOverrideField(rsvpLocaleEditing!, field.field, 'label', v)} + styles={style({ width: 200 })} + /> + setLocaleOverrideField(rsvpLocaleEditing!, field.field, 'placeholder', v)} + styles={style({ width: 200 })} + /> + {(field.type === 'select' || field.type === 'multi-select') && ( + setLocaleOverrideOptions(rsvpLocaleEditing!, field.field, v)} + styles={style({ width: 200 })} + /> + )} +
+ ) + })} +
+
+ )} +
+
+
+ + + + + + )} +
+ + + {/* Locales Config Create/Edit Dialog */} + +
+ + {({close}) => ( + <> + {editingLocalesConfig ? 'Edit Locales Config' : 'Create Locales Config'} + +
+
+ Locale Entries + +
+ {localeEntries.map((entry, index) => ( +
+
+ Locale {index + 1} + setLocaleEntries(prev => prev.filter((_, i) => i !== index))} + isDisabled={localeEntries.length <= 1} + > + + +
+
+ setLocaleEntries(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], code: v } + return copy + })} + styles={style({ width: 140 })} + isRequired + /> + setLocaleEntries(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], name: v } + return copy + })} + styles={style({ width: 220 })} + isRequired + /> + setLocaleEntries(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], urlCode: v } + return copy + })} + styles={style({ width: 100 })} + /> +
+
+ ))} +
+
+ + + + + + )} +
+ + + {/* Custom Attribute Create/Edit Dialog */} + +
+ + {({close}) => ( + <> + {editingAttr ? 'Edit Custom Attribute' : 'Create Custom Attribute'} + +
+ + { + setAttrFormInputType(key as CustomAttributeInputType) + // Clear values when switching to non-select types + if (key === 'text' || key === 'boolean') { + setAttrFormValues([]) + } + }} + styles={style({ width: '[100%]' })} + > + {ATTRIBUTE_INPUT_TYPES.map(t => ( + {t.label} + ))} + + + {(attrFormInputType === 'single-select' || attrFormInputType === 'multi-select') && ( +
+
+ Values + +
+
+ {attrFormValues.map((val, index) => ( +
+ setAttrFormValues(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], value: v } + return copy + })} + styles={style({ flexGrow: 1, width: '[100%]' })} + /> + setAttrFormValues(prev => + prev.filter((_, i) => i !== index).map((v, i) => ({ ...v, displayOrder: i })) + )} + > + + +
+ ))} + {attrFormValues.length === 0 && ( + + No values yet. Add values for users to select from. + + )} +
+
+ )} +
+
+ + + + + + )} +
+ + + {/* Delete Confirmations */} + !open && setRsvpConfigToDelete(null)} + > +
+ { if (rsvpConfigToDelete) handleDeleteConfig(rsvpConfigToDelete) }} + onCancel={() => setRsvpConfigToDelete(null)} + isPrimaryActionDisabled={isSaving} + > + Delete the RSVP config for this scope? This action cannot be undone. + + + + !open && setLocalesToDelete(null)} + > +
+ { if (localesToDelete) handleDeleteConfig(localesToDelete) }} + onCancel={() => setLocalesToDelete(null)} + isPrimaryActionDisabled={isSaving} + > + Delete the locales config for this scope? This action cannot be undone. + + + + !open && setAttrToDelete(null)} + > +
+ { if (attrToDelete) handleDeleteAttr(attrToDelete) }} + onCancel={() => setAttrToDelete(null)} + isPrimaryActionDisabled={isSaving} + > + Delete attribute {attrToDelete?.name}? This action cannot be undone. + + + + {/* Loading overlays */} + + +
+ ) +} + +export default ConfigManagement From b26a0e54984a479bd25095608b56ee12ebe36033 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Wed, 8 Apr 2026 08:36:27 -0700 Subject: [PATCH 02/23] Configs Service WIP --- .../scope-configs-fe-integration-guide.md | 817 ++++++++++++++++++ web-src/src/components/App.tsx | 2 + web-src/src/components/layout/TopNav.tsx | 9 + web-src/src/pages/ConfigManagement/index.ts | 1 + web-src/src/pages/Home.tsx | 9 + web-src/src/pages/index.ts | 1 + web-src/src/services/api.ts | 214 +++++ web-src/src/types/configApi.ts | 162 ++++ web-src/src/types/index.ts | 3 + 9 files changed, 1218 insertions(+) create mode 100644 .claude/temp-specs/scope-configs-fe-integration-guide.md create mode 100644 web-src/src/pages/ConfigManagement/index.ts create mode 100644 web-src/src/types/configApi.ts diff --git a/.claude/temp-specs/scope-configs-fe-integration-guide.md b/.claude/temp-specs/scope-configs-fe-integration-guide.md new file mode 100644 index 0000000..f241528 --- /dev/null +++ b/.claude/temp-specs/scope-configs-fe-integration-guide.md @@ -0,0 +1,817 @@ +# Scope Configs & Custom Attributes — FE Integration Guide + +## Overview + +Scope configs and custom attributes provide configurable, scope-inherited settings for the events console. Configs hold typed configuration data (RSVP form fields, locales). Custom attributes define admin-created fields that marketers fill in per event. + +Both inherit down the scope hierarchy: **org → team**. FE reads them via convenience endpoints that resolve the chain automatically. + +--- + +## API Endpoints & Sample Responses + +### 1. List Configs for an Event + +``` +GET /v1/events/{eventId}/configs +GET /v1/events/{eventId}/configs?type=rsvp +``` + +**Sample Response — all configs:** +```json +{ + "configs": [ + { + "configId": "5bff274f-4f58-419e-8729-9096fac8b737", + "type": "rsvp", + "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", + "creationTime": 1712345678000, + "modificationTime": 1712345678000, + "rsvpFormFields": [ + { + "field": "firstName", + "label": "First name", + "placeholder": "First Name", + "type": "text", + "required": true, + "options": [], + "rules": "", + "default": "", + "displayAs": "dropdown" + }, + { + "field": "lastName", + "label": "Last name", + "placeholder": "Last Name", + "type": "text", + "required": true, + "options": [], + "rules": "", + "default": "", + "displayAs": "dropdown" + }, + { + "field": "email", + "label": "Email", + "placeholder": "Email", + "type": "email", + "required": true, + "options": [], + "rules": "", + "default": "", + "displayAs": "dropdown" + }, + { + "field": "jobTitle", + "label": "Job title", + "placeholder": "", + "type": "select", + "required": true, + "options": [ + "Art or Creative Director", + "Animator", + "Artist / Illustrator", + "Graphic Designer", + "UI / UX Designer", + "Developer", + "Marketer / Digital Content Creator", + "Student", + "Other" + ], + "rules": "", + "default": "", + "displayAs": "dropdown" + }, + { + "field": "productsOfInterest", + "label": "Products of interest", + "placeholder": "", + "type": "multi-select", + "required": false, + "options": [ + "Acrobat Pro", + "Adobe Express", + "Adobe Firefly", + "Adobe Photoshop", + "Illustrator", + "InDesign", + "Lightroom", + "Premiere Pro" + ], + "rules": "", + "default": "", + "displayAs": "checkbox" + }, + { + "field": "companySize", + "label": "Company size", + "placeholder": "", + "type": "select", + "required": false, + "options": [ + "1", + "2 - 9", + "10 - 49", + "50 - 99", + "100 - 199", + "200 - 499", + "500 - 999", + "1,000 - 2,499", + "2,500 - 4,999", + "5,000 - 9,999", + "10,000 or more", + "Don't know" + ], + "rules": "", + "default": "", + "displayAs": "radio" + } + ], + "enabledAttributes": [ + { + "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", + "name": "primaryProductName" + }, + { + "attributeId": "f75144b3-74a7-4a66-b2f5-2987d154546f", + "name": "promotionalContent" + } + ], + "localizations": { + "fr-FR": { + "rsvpFormFields": [ + { "field": "firstName", "label": "Prénom", "placeholder": "Prénom" }, + { "field": "lastName", "label": "Nom de famille", "placeholder": "Nom de famille" }, + { "field": "email", "label": "Courriel", "placeholder": "Courriel" }, + { "field": "jobTitle", "label": "Titre du poste", "options": [ + "Directeur artistique", + "Animateur", + "Artiste / Illustrateur", + "Designer graphique", + "Designer UI / UX", + "Développeur", + "Spécialiste marketing", + "Étudiant", + "Autre" + ]}, + { "field": "productsOfInterest", "label": "Produits d'intérêt" }, + { "field": "companySize", "label": "Taille de l'entreprise" } + ] + } + } + }, + { + "configId": "0ae6adb8-a3be-4ad1-997b-8184c71b051c", + "type": "locales", + "scopeId": "25f26faa-f8e2-4e99-b341-81a3995ef9af", + "creationTime": 1712345700000, + "modificationTime": 1712345700000, + "localeNames": { + "en-US": "English, United States", + "fr-FR": "French, France", + "de-DE": "German, Germany", + "ja-JP": "Japanese, Japan" + }, + "localeUrlCodes": { + "en-US": "", + "fr-FR": "fr", + "de-DE": "de", + "ja-JP": "jp" + } + }, + { + "configId": "02510358-21b8-40ab-b693-44513051a3aa", + "type": "custom-attributes", + "scopeId": "25f26faa-f8e2-4e99-b341-81a3995ef9af", + "creationTime": 1712345800000, + "modificationTime": 1712345800000, + "enabledAttributes": [ + { + "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", + "name": "primaryProductName" + } + ] + } + ], + "count": 3, + "nextPageToken": null +} +``` + +**Sample Response — filtered by type (`?type=rsvp`):** +```json +{ + "configs": [ + { + "configId": "5bff274f-4f58-419e-8729-9096fac8b737", + "type": "rsvp", + "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", + "rsvpFormFields": [ "...same as above..." ], + "enabledAttributes": [ "...same as above..." ], + "localizations": { "...same as above..." } + } + ], + "count": 1, + "nextPageToken": null +} +``` + +**Sample Response — no configs found:** +```json +{ + "configs": [], + "count": 0 +} +``` + +--- + +### 2. List Configs for a Series + +``` +GET /v1/series/{seriesId}/configs +GET /v1/series/{seriesId}/configs?type=locales +``` + +Same response shape as event configs. The series → scope resolution happens server-side. + +--- + +### 3. List Custom Attributes for an Event + +``` +GET /v1/events/{eventId}/custom-attributes +``` + +**Sample Response:** +```json +{ + "customAttributes": [ + { + "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", + "name": "primaryProductName", + "inputType": "single-select", + "values": [ + { "valueId": "a1b2c3d4-0001", "value": "Photoshop", "displayOrder": 0 }, + { "valueId": "a1b2c3d4-0002", "value": "Illustrator", "displayOrder": 1 }, + { "valueId": "a1b2c3d4-0003", "value": "Premiere Pro", "displayOrder": 2 } + ], + "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", + "creationTime": 1712340000000, + "modificationTime": 1712340000000 + }, + { + "attributeId": "f75144b3-74a7-4a66-b2f5-2987d154546f", + "name": "promotionalContent", + "inputType": "multi-select", + "values": [ + { "valueId": "b2c3d4e5-0001", "value": "Blog Post", "displayOrder": 0 }, + { "valueId": "b2c3d4e5-0002", "value": "Social Media", "displayOrder": 1 }, + { "valueId": "b2c3d4e5-0003", "value": "Email Campaign", "displayOrder": 2 }, + { "valueId": "b2c3d4e5-0004", "value": "Video", "displayOrder": 3 } + ], + "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", + "creationTime": 1712341000000, + "modificationTime": 1712341000000 + }, + { + "attributeId": "c3d4e5f6-1111-2222-3333-444455556666", + "name": "splashPageKey", + "inputType": "text", + "values": [], + "scopeId": "25f26faa-f8e2-4e99-b341-81a3995ef9af", + "creationTime": 1712342000000, + "modificationTime": 1712342000000 + }, + { + "attributeId": "d4e5f6a7-1111-2222-3333-444455556666", + "name": "isVipEvent", + "inputType": "boolean", + "values": [], + "scopeId": "25f26faa-f8e2-4e99-b341-81a3995ef9af", + "creationTime": 1712343000000, + "modificationTime": 1712343000000 + } + ], + "count": 4 +} +``` + +--- + +### 4. List Custom Attributes for a Series + +``` +GET /v1/series/{seriesId}/custom-attributes +``` + +Same response shape as event custom attributes. + +--- + +### 5. Create a Config (Admin) + +``` +POST /v1/scopes/{scopeId}/configs +Content-Type: application/json + +{ + "type": "rsvp", + "rsvpFormFields": [ + { + "field": "firstName", + "label": "First name", + "placeholder": "First Name", + "type": "text", + "required": true + } + ], + "enabledAttributes": [ + { "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", "name": "primaryProductName" } + ] +} +``` + +**Sample Response (201):** +```json +{ + "configId": "7a8b9c0d-1234-5678-9abc-def012345678", + "type": "rsvp", + "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", + "creationTime": 1712345678000, + "modificationTime": 1712345678000, + "rsvpFormFields": [ + { + "field": "firstName", + "label": "First name", + "placeholder": "First Name", + "type": "text", + "required": true + } + ], + "enabledAttributes": [ + { "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", "name": "primaryProductName" } + ] +} +``` + +**Error — duplicate type (409):** +```json +{ + "message": "A config with type 'rsvp' already exists for this scope" +} +``` + +**Error — platform scope (400):** +```json +{ + "message": "Configs cannot be created at the platform scope level. Use org or team scopes." +} +``` + +--- + +### 6. Update a Config (Admin) + +``` +PUT /v1/scopes/{scopeId}/configs/{configId} +Content-Type: application/json + +{ + "configId": "7a8b9c0d-1234-5678-9abc-def012345678", + "type": "rsvp", + "rsvpFormFields": [ + { "field": "firstName", "label": "First name", "type": "text", "required": true }, + { "field": "email", "label": "Email", "type": "email", "required": true } + ] +} +``` + +**Sample Response (200):** +```json +{ + "configId": "7a8b9c0d-1234-5678-9abc-def012345678", + "type": "rsvp", + "modificationTime": 1712346000000, + "rsvpFormFields": [ + { "field": "firstName", "label": "First name", "type": "text", "required": true }, + { "field": "email", "label": "Email", "type": "email", "required": true } + ] +} +``` + +--- + +### 7. Delete a Config (Admin) + +``` +DELETE /v1/scopes/{scopeId}/configs/{configId} +``` + +**Response: 204 No Content** (empty body) + +--- + +### 8. Create a Custom Attribute (Admin) + +``` +POST /v1/scopes/{scopeId}/custom-attributes +Content-Type: application/json + +{ + "name": "targetAudience", + "inputType": "single-select", + "values": [ + { "value": "Enterprise" }, + { "value": "SMB" }, + { "value": "Education" }, + { "value": "Government" } + ] +} +``` + +**Sample Response (201):** +```json +{ + "attributeId": "e5f6a7b8-1234-5678-9abc-def012345678", + "name": "targetAudience", + "inputType": "single-select", + "values": [ + { "valueId": "f6a7b8c9-0001", "value": "Enterprise", "displayOrder": 0 }, + { "valueId": "f6a7b8c9-0002", "value": "SMB", "displayOrder": 1 }, + { "valueId": "f6a7b8c9-0003", "value": "Education", "displayOrder": 2 }, + { "valueId": "f6a7b8c9-0004", "value": "Government", "displayOrder": 3 } + ], + "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", + "creationTime": 1712350000000, + "modificationTime": 1712350000000 +} +``` + +Note: `valueId` and `displayOrder` are auto-generated by the server. You only need to send `value` in the request. + +**Error — platform scope (400):** +```json +{ + "message": "Custom attributes cannot be created at the platform scope level. Use org or team scopes." +} +``` + +**Error — missing name (400):** +```json +{ + "message": "name is required when creating a custom attribute" +} +``` + +--- + +### 9. Update a Custom Attribute (Admin) + +``` +PUT /v1/scopes/{scopeId}/custom-attributes/{attributeId} +Content-Type: application/json + +{ + "attributeId": "e5f6a7b8-1234-5678-9abc-def012345678", + "name": "targetAudience", + "inputType": "single-select", + "values": [ + { "valueId": "f6a7b8c9-0001", "value": "Enterprise", "displayOrder": 0 }, + { "valueId": "f6a7b8c9-0002", "value": "SMB", "displayOrder": 1 }, + { "valueId": "f6a7b8c9-0003", "value": "Education", "displayOrder": 2 }, + { "valueId": "f6a7b8c9-0004", "value": "Government", "displayOrder": 3 }, + { "value": "Non-Profit" } + ] +} +``` + +**Sample Response (200):** +```json +{ + "attributeId": "e5f6a7b8-1234-5678-9abc-def012345678", + "name": "targetAudience", + "inputType": "single-select", + "values": [ + { "valueId": "f6a7b8c9-0001", "value": "Enterprise", "displayOrder": 0 }, + { "valueId": "f6a7b8c9-0002", "value": "SMB", "displayOrder": 1 }, + { "valueId": "f6a7b8c9-0003", "value": "Education", "displayOrder": 2 }, + { "valueId": "f6a7b8c9-0004", "value": "Government", "displayOrder": 3 }, + { "valueId": "a7b8c9d0-0005", "value": "Non-Profit", "displayOrder": 4 } + ], + "modificationTime": 1712351000000 +} +``` + +Note: New values without a `valueId` get one auto-generated. Existing values with `valueId` are preserved. + +--- + +### 10. Delete a Custom Attribute (Admin) + +``` +DELETE /v1/scopes/{scopeId}/custom-attributes/{attributeId} +``` + +**Response: 204 No Content** (empty body) + +--- + +### 11. Get a Single Config + +``` +GET /v1/scopes/{scopeId}/configs/{configId} +``` + +**Sample Response (200):** +```json +{ + "configId": "5bff274f-4f58-419e-8729-9096fac8b737", + "type": "rsvp", + "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", + "creationTime": 1712345678000, + "modificationTime": 1712345678000, + "rsvpFormFields": [ ... ], + "enabledAttributes": [ ... ], + "localizations": { + "fr-FR": { "rsvpFormFields": [ ... ] } + } +} +``` + +--- + +### 12. Get a Single Custom Attribute + +``` +GET /v1/scopes/{scopeId}/custom-attributes/{attributeId} +``` + +**Sample Response (200):** +```json +{ + "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", + "name": "primaryProductName", + "inputType": "single-select", + "values": [ + { "valueId": "a1b2c3d4-0001", "value": "Photoshop", "displayOrder": 0 }, + { "valueId": "a1b2c3d4-0002", "value": "Illustrator", "displayOrder": 1 }, + { "valueId": "a1b2c3d4-0003", "value": "Premiere Pro", "displayOrder": 2 } + ], + "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", + "creationTime": 1712340000000, + "modificationTime": 1712340000000 +} +``` + +--- + +### 13. List Configs for a Scope (Admin — with hierarchy merge) + +``` +GET /v1/scopes/{scopeId}/configs +GET /v1/scopes/{scopeId}/configs?type=rsvp +``` + +Same response shape as event/series configs. Includes configs inherited from parent scopes. Each config's `scopeId` tells you where it originated. + +--- + +## Required Headers + +``` +Authorization: Bearer {ims_token} +x-adobe-esp-group-id: {group_id} +Content-Type: application/json ← only for POST/PUT +``` + +--- + +## Integration Patterns + +### 1. Loading RSVP Form Fields for Event Registration + +```javascript +const res = await fetch(`/v1/events/${eventId}/configs?type=rsvp`, { headers }); +const { configs } = await res.json(); +const rsvpConfig = configs[0]; // one config per type + +if (rsvpConfig) { + const formFields = rsvpConfig.rsvpFormFields || []; + + formFields.forEach(field => { + // field.field → "jobTitle" (API key) + // field.label → "Job title" (display text) + // field.placeholder → "Job Title" + // field.type → "select" | "multi-select" | "text" | "email" | "phone" + // field.displayAs → "dropdown" | "radio" | "checkbox" (for select types) + // field.required → true/false + // field.options → ["Option A", "Option B", ...] (for select types) + // field.default → default value + // field.rules → "full-width" etc. + }); +} +``` + +### 2. Loading Locales for Event Creation + +```javascript +const res = await fetch(`/v1/series/${seriesId}/configs?type=locales`, { headers }); +const { configs } = await res.json(); +const localesConfig = configs[0]; + +if (localesConfig) { + const localeNames = localesConfig.localeNames; + // { "en-US": "English, United States", "fr-FR": "French, France" } + // → populate locale dropdown + + const localeUrlCodes = localesConfig.localeUrlCodes; + // { "en-US": "", "fr-FR": "fr" } + // → used for building detail page paths +} +``` + +### 3. Loading Custom Attributes for Event Form + +```javascript +const res = await fetch(`/v1/events/${eventId}/custom-attributes`, { headers }); +const { customAttributes } = await res.json(); + +customAttributes.forEach(attr => { + switch (attr.inputType) { + case 'text': + // render text input + break; + case 'boolean': + // render checkbox/toggle + break; + case 'single-select': + // render dropdown or radio group using attr.values + // each value: { valueId, value, displayOrder } + break; + case 'multi-select': + // render multi-select or checkbox group using attr.values + break; + } +}); +``` + +### 4. Saving Custom Attribute Values on an Event + +```javascript +const customAttributes = []; + +// Multi-select: one entry per selected value +customAttributes.push( + { + attributeId: "52ff35ec-...", + attribute: "primaryProductName", // denormalized name + valueId: "a1b2c3d4-0001", + value: "Photoshop", // denormalized value + displayOrder: 0 + } +); + +// Multi-select with multiple selections +customAttributes.push( + { attributeId: "f75144b3-...", attribute: "promotionalContent", valueId: "b2c3d4e5-0001", value: "Blog Post", displayOrder: 0 }, + { attributeId: "f75144b3-...", attribute: "promotionalContent", valueId: "b2c3d4e5-0004", value: "Video", displayOrder: 3 } +); + +// Text attribute +customAttributes.push({ + attributeId: "c3d4e5f6-...", + attribute: "splashPageKey", + value: "summit-2026-splash" +}); + +// Boolean attribute +customAttributes.push({ + attributeId: "d4e5f6a7-...", + attribute: "isVipEvent", + value: "true" +}); + +// Save on event +await fetch(`/v1/events/${eventId}`, { + method: 'PUT', + headers, + body: JSON.stringify({ ...eventData, customAttributes }) +}); +``` + +### 5. Using Config-Linked Custom Attributes + +```javascript +// Load RSVP config + all custom attributes in parallel +const [configRes, attrRes] = await Promise.all([ + fetch(`/v1/events/${eventId}/configs?type=rsvp`, { headers }), + fetch(`/v1/events/${eventId}/custom-attributes`, { headers }) +]); +const { configs } = await configRes.json(); +const { customAttributes } = await attrRes.json(); +const rsvpConfig = configs[0]; + +// Filter to only attributes enabled for this config +const enabledIds = new Set((rsvpConfig?.enabledAttributes || []).map(a => a.attributeId)); +const rsvpAttributes = customAttributes.filter(a => enabledIds.has(a.attributeId)); + +// Render: RSVP form fields + rsvpAttributes together in the registration form +``` + +### 6. Handling RSVP Localizations + +```javascript +const rsvpConfig = configs[0]; +const userLocale = event.defaultLocale; // e.g. "fr-FR" + +const baseFields = rsvpConfig.rsvpFormFields || []; +const localeOverrides = rsvpConfig.localizations?.[userLocale]?.rsvpFormFields || []; + +// Merge: localized values override base, fall back to base if no translation +const localizedFields = baseFields.map(field => { + const override = localeOverrides.find(o => o.field === field.field); + return { + ...field, + label: override?.label || field.label, + placeholder: override?.placeholder || field.placeholder, + options: override?.options || field.options + }; +}); +``` + +--- + +## Hierarchy Behavior + +| Resource | Merge Strategy | Example | +|----------|---------------|---------| +| Configs | **Full replace** per type | Team's `rsvp` config fully replaces org's `rsvp` config | +| Custom Attributes | **Accumulate** | Team sees own attributes + all org attributes | + +Each item in the response includes a `scopeId` field indicating where it was originally defined. + +--- + +## Input Type Reference + +### Custom Attribute `inputType` Values + +| Value | Render As | Values Array | +|-------|-----------|-------------| +| `text` | Text input | Not used | +| `boolean` | Checkbox/toggle | Not used | +| `single-select` | Dropdown or radio buttons | Required — list of options | +| `multi-select` | Multi-dropdown or checkboxes | Required — list of options | + +### RSVP Form Field `type` Values + +| Value | Render As | +|-------|-----------| +| `text` | Text input | +| `email` | Email input (with validation) | +| `phone` | Phone input | +| `select` | Dropdown or radio buttons (check `displayAs`) | +| `multi-select` | Multi-dropdown or checkboxes (check `displayAs`) | + +### RSVP Form Field `displayAs` Values + +| Value | Applies To | Render As | +|-------|-----------|-----------| +| `dropdown` | `select` | Standard dropdown | +| `radio` | `select` | Radio button group | +| `dropdown` | `multi-select` | Multi-select dropdown | +| `checkbox` | `multi-select` | Checkbox group | + +--- + +## Error Responses + +All errors return JSON with a `message` field: + +```json +{ "message": "Human-readable error description" } +``` + +| Status | Meaning | Example Message | +|--------|---------|-----------------| +| 200 | Success | — | +| 201 | Created | — | +| 204 | Deleted (empty body) | — | +| 400 | Bad request | `"type is required when creating a config"` | +| 400 | Platform restriction | `"Configs cannot be created at the platform scope level. Use org or team scopes."` | +| 403 | Forbidden | `"Insufficient permissions"` | +| 404 | Not found | `"Config not found"` / `"Scope not found"` | +| 409 | Duplicate | `"A config with type 'rsvp' already exists for this scope"` | +| 500 | Server error | `"Internal server error"` | + +--- + +## Notes + +- Configs and custom attributes **cannot** be created at the platform scope level — only org and team scopes +- The `?type=` query parameter filters configs by type; omit it to get all configs +- Custom attribute values on events are **denormalized** — both IDs and display names are stored for fast reads +- RSVP form field labels and select options support **localization** via the `localizations` object on the config +- New custom attribute values without a `valueId` get one auto-generated on create/update +- `displayOrder` is auto-assigned by array index if not provided \ No newline at end of file diff --git a/web-src/src/components/App.tsx b/web-src/src/components/App.tsx index 5f5b866..e1443ec 100644 --- a/web-src/src/components/App.tsx +++ b/web-src/src/components/App.tsx @@ -47,6 +47,7 @@ import { UserManagement, ScopeGroupManagement, RoleManagement, + ConfigManagement, } from '../pages' interface AppProps { @@ -136,6 +137,7 @@ const AppContent: React.FC<{ runtime: Runtime, colorScheme: ColorScheme }> = ({ } /> } /> } /> + } /> }/> diff --git a/web-src/src/components/layout/TopNav.tsx b/web-src/src/components/layout/TopNav.tsx index d6dffd0..a67ef6c 100644 --- a/web-src/src/components/layout/TopNav.tsx +++ b/web-src/src/components/layout/TopNav.tsx @@ -23,6 +23,7 @@ const TopNav: React.FC = ({ ims }) => { const { isLoading: isGroupLoading, activeGroup } = useGroup() const canReadEvents = useHasPermission('event', 'read') const canReadSeries = useHasPermission('series', 'read') + const canReadConfig = useHasPermission('config', 'read') // Hide all tabs until a group is selected (loading done and activeGroup set) const showNav = !isGroupLoading && activeGroup !== null @@ -131,6 +132,14 @@ const TopNav: React.FC = ({ ims }) => { Series )} + {canReadConfig && ( + `nav-link ${isActive ? 'is-selected' : ''}`} + to="/configs" + > + Configs + + )} `nav-link ${isActive ? 'is-selected' : ''}`} to="/about" diff --git a/web-src/src/pages/ConfigManagement/index.ts b/web-src/src/pages/ConfigManagement/index.ts new file mode 100644 index 0000000..f1cad3e --- /dev/null +++ b/web-src/src/pages/ConfigManagement/index.ts @@ -0,0 +1 @@ +export { ConfigManagement } from './ConfigManagement' diff --git a/web-src/src/pages/Home.tsx b/web-src/src/pages/Home.tsx index 3538390..5019d40 100644 --- a/web-src/src/pages/Home.tsx +++ b/web-src/src/pages/Home.tsx @@ -13,6 +13,7 @@ import CalendarIllustration from '@react-spectrum/s2/illustrations/gradient/gene import UserGroupIllustration from '@react-spectrum/s2/illustrations/gradient/generic1/UserGroup' import MicrophoneIllustration from '@react-spectrum/s2/illustrations/gradient/generic1/Microphone' import LayersIllustration from '@react-spectrum/s2/illustrations/gradient/generic1/Layers' +import GearSettingIllustration from '@react-spectrum/s2/illustrations/gradient/generic1/GearSetting' import DocumentIllustration from '@react-spectrum/s2/illustrations/gradient/generic1/Document' import { GRADIENT_BACKGROUND, LAYOUT_DIMENSIONS, SPACING } from '../styles/designSystem' import { checkPermission } from '../hooks/useHasPermission' @@ -70,6 +71,14 @@ const destinations: NavDestination[] = [ description: 'Create and manage event series to group related events together.', permission: { resource: 'series', access: 'read' } }, + { + id: 'configs', + path: '/configs', + icon: , + title: 'Configs', + description: 'Manage RSVP fields, locale mappings, and custom attributes for your organization.', + permission: { resource: 'config', access: 'read' } + }, { id: 'about', path: '/about', diff --git a/web-src/src/pages/index.ts b/web-src/src/pages/index.ts index f914c09..c91459e 100644 --- a/web-src/src/pages/index.ts +++ b/web-src/src/pages/index.ts @@ -29,3 +29,4 @@ export { SeriesForm } from './SeriesForm' export { UserManagement } from './UserManagement' export { ScopeGroupManagement } from './ScopeGroupManagement' export { RoleManagement } from './RoleManagement' +export { ConfigManagement } from './ConfigManagement' diff --git a/web-src/src/services/api.ts b/web-src/src/services/api.ts index d773bbe..a7563cd 100644 --- a/web-src/src/services/api.ts +++ b/web-src/src/services/api.ts @@ -42,6 +42,15 @@ import type { PermissionsListResponse, ScopeType, } from '../types/rbacApi' +import type { + ConfigType, + ScopeConfig, + ConfigCreateBody, + ConfigUpdateBody, + CustomAttribute, + CustomAttributeCreateBody, + CustomAttributeUpdateBody, +} from '../types/configApi' // ============================================================================ // TYPES @@ -2324,6 +2333,153 @@ class ApiService { if ('error' in result) return result return result.permissions } + + // --- Scope Configs --- + + async getConfigsForScope(scopeId: string, type?: ConfigType): Promise { + validateString(scopeId, 'scope ID') + const baseParams: Record = {} + if (type) baseParams.type = type + return this.fetchAllPages({ + service: 'esp', + baseEndpoint: `/v1/scopes/${encodeURIComponent(scopeId)}/configs`, + listKey: 'configs', + baseParams, + operationName: 'getConfigsForScope' + }) + } + + async getConfigById(scopeId: string, configId: string): Promise { + validateString(scopeId, 'scope ID') + validateString(configId, 'config ID') + return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/configs/${encodeURIComponent(configId)}`, 'GET', undefined, { + operationName: 'getConfigById', + shouldReturnFullResponse: true + }) + } + + async createConfig(scopeId: string, data: ConfigCreateBody): Promise { + validateString(scopeId, 'scope ID') + validateObject(data, 'config create body') + return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/configs`, 'POST', data, { + operationName: 'createConfig', + shouldReturnFullResponse: true + }) + } + + async updateConfig(scopeId: string, configId: string, data: ConfigUpdateBody): Promise { + validateString(scopeId, 'scope ID') + validateString(configId, 'config ID') + validateObject(data, 'config update body') + return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/configs/${encodeURIComponent(configId)}`, 'PUT', data, { + operationName: 'updateConfig', + shouldReturnFullResponse: true + }) + } + + async deleteConfig(scopeId: string, configId: string): Promise { + validateString(scopeId, 'scope ID') + validateString(configId, 'config ID') + return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/configs/${encodeURIComponent(configId)}`, 'DELETE', undefined, { + operationName: 'deleteConfig' + }) + } + + // --- Custom Attributes --- + + async getCustomAttributesForScope(scopeId: string): Promise { + validateString(scopeId, 'scope ID') + return this.fetchAllPages({ + service: 'esp', + baseEndpoint: `/v1/scopes/${encodeURIComponent(scopeId)}/custom-attributes`, + listKey: 'customAttributes', + operationName: 'getCustomAttributesForScope' + }) + } + + async getCustomAttributeById(scopeId: string, attributeId: string): Promise { + validateString(scopeId, 'scope ID') + validateString(attributeId, 'attribute ID') + return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/custom-attributes/${encodeURIComponent(attributeId)}`, 'GET', undefined, { + operationName: 'getCustomAttributeById', + shouldReturnFullResponse: true + }) + } + + async createCustomAttribute(scopeId: string, data: CustomAttributeCreateBody): Promise { + validateString(scopeId, 'scope ID') + validateObject(data, 'custom attribute create body') + return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/custom-attributes`, 'POST', data, { + operationName: 'createCustomAttribute', + shouldReturnFullResponse: true + }) + } + + async updateCustomAttribute(scopeId: string, attributeId: string, data: CustomAttributeUpdateBody): Promise { + validateString(scopeId, 'scope ID') + validateString(attributeId, 'attribute ID') + validateObject(data, 'custom attribute update body') + return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/custom-attributes/${encodeURIComponent(attributeId)}`, 'PUT', data, { + operationName: 'updateCustomAttribute', + shouldReturnFullResponse: true + }) + } + + async deleteCustomAttribute(scopeId: string, attributeId: string): Promise { + validateString(scopeId, 'scope ID') + validateString(attributeId, 'attribute ID') + return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/custom-attributes/${encodeURIComponent(attributeId)}`, 'DELETE', undefined, { + operationName: 'deleteCustomAttribute' + }) + } + + // --- Convenience Endpoints (resolved configs for events/series) --- + + async getEventConfigs(eventId: string, type?: ConfigType): Promise { + validateString(eventId, 'event ID') + const baseParams: Record = {} + if (type) baseParams.type = type + return this.fetchAllPages({ + service: 'esp', + baseEndpoint: `/v1/events/${encodeURIComponent(eventId)}/configs`, + listKey: 'configs', + baseParams, + operationName: 'getEventConfigs' + }) + } + + async getSeriesConfigs(seriesId: string, type?: ConfigType): Promise { + validateString(seriesId, 'series ID') + const baseParams: Record = {} + if (type) baseParams.type = type + return this.fetchAllPages({ + service: 'esp', + baseEndpoint: `/v1/series/${encodeURIComponent(seriesId)}/configs`, + listKey: 'configs', + baseParams, + operationName: 'getSeriesConfigs' + }) + } + + async getEventCustomAttributes(eventId: string): Promise { + validateString(eventId, 'event ID') + return this.fetchAllPages({ + service: 'esp', + baseEndpoint: `/v1/events/${encodeURIComponent(eventId)}/custom-attributes`, + listKey: 'customAttributes', + operationName: 'getEventCustomAttributes' + }) + } + + async getSeriesCustomAttributes(seriesId: string): Promise { + validateString(seriesId, 'series ID') + return this.fetchAllPages({ + service: 'esp', + baseEndpoint: `/v1/series/${encodeURIComponent(seriesId)}/custom-attributes`, + listKey: 'customAttributes', + operationName: 'getSeriesCustomAttributes' + }) + } } // ============================================================================ @@ -2483,6 +2639,20 @@ export const cachedApi = { getEventPublishingProfile: (eventId: string) => apiCache.get((id: string) => apiService.getEventPublishingProfile(id), eventId), getCaasTags: () => apiService.getCaasTags(), // Already has internal caching + // === CONFIGS (GET Operations - Cached) === + getConfigsForScope: (scopeId: string, type?: ConfigType) => + apiCache.get((id: string, t?: ConfigType) => apiService.getConfigsForScope(id, t), scopeId, type), + getCustomAttributesForScope: (scopeId: string) => + apiCache.get((id: string) => apiService.getCustomAttributesForScope(id), scopeId), + getEventConfigs: (eventId: string, type?: ConfigType) => + apiCache.get((id: string, t?: ConfigType) => apiService.getEventConfigs(id, t), eventId, type), + getSeriesConfigs: (seriesId: string, type?: ConfigType) => + apiCache.get((id: string, t?: ConfigType) => apiService.getSeriesConfigs(id, t), seriesId, type), + getEventCustomAttributes: (eventId: string) => + apiCache.get((id: string) => apiService.getEventCustomAttributes(id), eventId), + getSeriesCustomAttributes: (seriesId: string) => + apiCache.get((id: string) => apiService.getSeriesCustomAttributes(id), seriesId), + // === MUTATIONS (with cache invalidation) === // Series Mutations @@ -2597,6 +2767,50 @@ export const cachedApi = { return result }, + // Config Mutations + async createConfig(scopeId: string, data: ConfigCreateBody) { + const result = await apiService.createConfig(scopeId, data) + apiCache.invalidate(scopeId) + apiCache.invalidate('getConfigsForScope') + return result + }, + async updateConfig(scopeId: string, configId: string, data: ConfigUpdateBody) { + const result = await apiService.updateConfig(scopeId, configId, data) + apiCache.invalidate(scopeId) + apiCache.invalidate(configId) + apiCache.invalidate('getConfigsForScope') + return result + }, + async deleteConfig(scopeId: string, configId: string) { + const result = await apiService.deleteConfig(scopeId, configId) + apiCache.invalidate(scopeId) + apiCache.invalidate(configId) + apiCache.invalidate('getConfigsForScope') + return result + }, + + // Custom Attribute Mutations + async createCustomAttribute(scopeId: string, data: CustomAttributeCreateBody) { + const result = await apiService.createCustomAttribute(scopeId, data) + apiCache.invalidate(scopeId) + apiCache.invalidate('getCustomAttributesForScope') + return result + }, + async updateCustomAttribute(scopeId: string, attributeId: string, data: CustomAttributeUpdateBody) { + const result = await apiService.updateCustomAttribute(scopeId, attributeId, data) + apiCache.invalidate(scopeId) + apiCache.invalidate(attributeId) + apiCache.invalidate('getCustomAttributesForScope') + return result + }, + async deleteCustomAttribute(scopeId: string, attributeId: string) { + const result = await apiService.deleteCustomAttribute(scopeId, attributeId) + apiCache.invalidate(scopeId) + apiCache.invalidate(attributeId) + apiCache.invalidate('getCustomAttributesForScope') + return result + }, + // === UTILITY METHODS === /** diff --git a/web-src/src/types/configApi.ts b/web-src/src/types/configApi.ts new file mode 100644 index 0000000..54e5999 --- /dev/null +++ b/web-src/src/types/configApi.ts @@ -0,0 +1,162 @@ +/** + * Scope Configs & Custom Attributes API type definitions + * + * Types matching the ESP configs/custom-attributes endpoints. + * Configs are scope-inherited (org -> team) and support three types: + * rsvp, locales, and custom-attributes. + */ + +// ============================================================================ +// Enums & Primitives +// ============================================================================ + +export type ConfigType = 'rsvp' | 'locales' | 'custom-attributes' + +export type RsvpFieldType = 'text' | 'email' | 'phone' | 'select' | 'multi-select' + +export type RsvpDisplayAs = 'dropdown' | 'radio' | 'checkbox' | '' + +export type CustomAttributeInputType = 'text' | 'boolean' | 'single-select' | 'multi-select' + +// ============================================================================ +// RSVP Form Field Models +// ============================================================================ + +export interface RsvpFormField { + field: string + label: string + placeholder: string + type: RsvpFieldType + required: boolean + options: string[] + rules: string + default: string + displayAs: RsvpDisplayAs +} + +/** Partial RSVP field for localization overrides (only translatable properties) */ +export interface RsvpFormFieldLocaleOverride { + field: string + label?: string + placeholder?: string + options?: string[] +} + +// ============================================================================ +// Enabled Attribute Reference +// ============================================================================ + +export interface EnabledAttributeRef { + attributeId: string + name: string +} + +// ============================================================================ +// Scope Config Models +// ============================================================================ + +interface ScopeConfigBase { + configId: string + type: ConfigType + scopeId: string + creationTime: number + modificationTime: number +} + +export interface RsvpScopeConfig extends ScopeConfigBase { + type: 'rsvp' + rsvpFormFields: RsvpFormField[] + enabledAttributes: EnabledAttributeRef[] + localizations: Record +} + +export interface LocalesScopeConfig extends ScopeConfigBase { + type: 'locales' + localeNames: Record + localeUrlCodes: Record +} + +export interface CustomAttributesScopeConfig extends ScopeConfigBase { + type: 'custom-attributes' + enabledAttributes: EnabledAttributeRef[] +} + +export type ScopeConfig = RsvpScopeConfig | LocalesScopeConfig | CustomAttributesScopeConfig + +// ============================================================================ +// Custom Attribute Models +// ============================================================================ + +export interface CustomAttributeValue { + valueId?: string + value: string + displayOrder: number +} + +export interface CustomAttribute { + attributeId: string + name: string + inputType: CustomAttributeInputType + values: CustomAttributeValue[] + scopeId: string + creationTime: number + modificationTime: number +} + +// ============================================================================ +// Request Bodies +// ============================================================================ + +export interface RsvpConfigCreateBody { + type: 'rsvp' + rsvpFormFields: RsvpFormField[] + enabledAttributes?: EnabledAttributeRef[] + localizations?: Record +} + +export interface LocalesConfigCreateBody { + type: 'locales' + localeNames: Record + localeUrlCodes: Record +} + +export interface CustomAttributesConfigCreateBody { + type: 'custom-attributes' + enabledAttributes: EnabledAttributeRef[] +} + +export type ConfigCreateBody = + | RsvpConfigCreateBody + | LocalesConfigCreateBody + | CustomAttributesConfigCreateBody + +export type ConfigUpdateBody = ScopeConfig + +export interface CustomAttributeCreateBody { + name: string + inputType: CustomAttributeInputType + values?: Array<{ value: string }> +} + +export interface CustomAttributeUpdateBody { + attributeId: string + name: string + inputType: CustomAttributeInputType + values: CustomAttributeValue[] +} + +// ============================================================================ +// Response Envelopes +// ============================================================================ + +export interface ConfigListResponse { + configs: ScopeConfig[] + count: number + nextPageToken?: string | null +} + +export interface CustomAttributeListResponse { + customAttributes: CustomAttribute[] + count: number + nextPageToken?: string | null +} diff --git a/web-src/src/types/index.ts b/web-src/src/types/index.ts index cfb74f6..a1f5924 100644 --- a/web-src/src/types/index.ts +++ b/web-src/src/types/index.ts @@ -29,3 +29,6 @@ export * from './rbac' // RBAC API types (scopes, groups, roles, permissions) export * from './rbacApi' + +// Config API types (scope configs, custom attributes) +export * from './configApi' From 173b0c16cb120c730827535a15b3e4194e511ba9 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Wed, 8 Apr 2026 13:34:01 -0700 Subject: [PATCH 03/23] Update customAttributes contract --- .../scope-configs-fe-integration-guide.md | 782 ++++++------------ .../ConfigManagement/ConfigManagement.tsx | 225 ++--- web-src/src/services/api.ts | 99 --- web-src/src/types/configApi.ts | 40 +- 4 files changed, 372 insertions(+), 774 deletions(-) diff --git a/.claude/temp-specs/scope-configs-fe-integration-guide.md b/.claude/temp-specs/scope-configs-fe-integration-guide.md index f241528..de773c9 100644 --- a/.claude/temp-specs/scope-configs-fe-integration-guide.md +++ b/.claude/temp-specs/scope-configs-fe-integration-guide.md @@ -1,23 +1,55 @@ -# Scope Configs & Custom Attributes — FE Integration Guide +# Scope Configs — FE Integration Guide ## Overview -Scope configs and custom attributes provide configurable, scope-inherited settings for the events console. Configs hold typed configuration data (RSVP form fields, locales). Custom attributes define admin-created fields that marketers fill in per event. +Scope configs provide configurable, scope-inherited settings for the events console. All configuration — RSVP form fields, locales, custom attributes — lives under a single config system with typed entries. Configs inherit down the scope hierarchy (org → team) with full-replace merge per type. -Both inherit down the scope hierarchy: **org → team**. FE reads them via convenience endpoints that resolve the chain automatically. +Custom attributes are a config type (`type: 'custom-attributes'`), not a separate entity. --- -## API Endpoints & Sample Responses +## API Endpoints -### 1. List Configs for an Event +### Reading Configs (most common FE use case) ``` -GET /v1/events/{eventId}/configs -GET /v1/events/{eventId}/configs?type=rsvp +GET /v1/events/{eventId}/configs → all configs for an event +GET /v1/events/{eventId}/configs?type=rsvp → just RSVP config +GET /v1/events/{eventId}/configs?type=locales → just locales config +GET /v1/events/{eventId}/configs?type=custom-attributes → just custom attribute definitions + +GET /v1/series/{seriesId}/configs → all configs for a series +GET /v1/series/{seriesId}/configs?type=rsvp → just RSVP config +``` + +### Admin Config Management + +``` +GET /v1/scopes/{scopeId}/configs → list configs (hierarchy merged) +POST /v1/scopes/{scopeId}/configs → create config +GET /v1/scopes/{scopeId}/configs/{configId} → get single config +PUT /v1/scopes/{scopeId}/configs/{configId} → update config +DELETE /v1/scopes/{scopeId}/configs/{configId} → delete config +``` + +### Required Headers + +``` +Authorization: Bearer {ims_token} +x-adobe-esp-group-id: {group_id} +Content-Type: application/json ← only for POST/PUT ``` -**Sample Response — all configs:** +### Permissions + +Config routes require `config:read`, `config:write`, or `config:delete` permissions on the user's role. + +--- + +## Sample Responses + +### GET /v1/events/{eventId}/configs — All configs + ```json { "configs": [ @@ -34,21 +66,10 @@ GET /v1/events/{eventId}/configs?type=rsvp "placeholder": "First Name", "type": "text", "required": true, + "displayAs": "dropdown", "options": [], "rules": "", - "default": "", - "displayAs": "dropdown" - }, - { - "field": "lastName", - "label": "Last name", - "placeholder": "Last Name", - "type": "text", - "required": true, - "options": [], - "rules": "", - "default": "", - "displayAs": "dropdown" + "default": "" }, { "field": "email", @@ -56,106 +77,46 @@ GET /v1/events/{eventId}/configs?type=rsvp "placeholder": "Email", "type": "email", "required": true, + "displayAs": "dropdown", "options": [], "rules": "", - "default": "", - "displayAs": "dropdown" + "default": "" }, { "field": "jobTitle", "label": "Job title", - "placeholder": "", "type": "select", "required": true, + "displayAs": "dropdown", "options": [ "Art or Creative Director", "Animator", - "Artist / Illustrator", - "Graphic Designer", - "UI / UX Designer", "Developer", - "Marketer / Digital Content Creator", + "Marketer", "Student", "Other" ], "rules": "", - "default": "", - "displayAs": "dropdown" + "default": "" }, { "field": "productsOfInterest", "label": "Products of interest", - "placeholder": "", "type": "multi-select", "required": false, - "options": [ - "Acrobat Pro", - "Adobe Express", - "Adobe Firefly", - "Adobe Photoshop", - "Illustrator", - "InDesign", - "Lightroom", - "Premiere Pro" - ], - "rules": "", - "default": "", - "displayAs": "checkbox" - }, - { - "field": "companySize", - "label": "Company size", - "placeholder": "", - "type": "select", - "required": false, - "options": [ - "1", - "2 - 9", - "10 - 49", - "50 - 99", - "100 - 199", - "200 - 499", - "500 - 999", - "1,000 - 2,499", - "2,500 - 4,999", - "5,000 - 9,999", - "10,000 or more", - "Don't know" - ], + "displayAs": "checkbox", + "options": ["Acrobat Pro", "Adobe Express", "Photoshop", "Illustrator", "Premiere Pro"], "rules": "", - "default": "", - "displayAs": "radio" - } - ], - "enabledAttributes": [ - { - "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", - "name": "primaryProductName" - }, - { - "attributeId": "f75144b3-74a7-4a66-b2f5-2987d154546f", - "name": "promotionalContent" + "default": "" } ], "localizations": { "fr-FR": { "rsvpFormFields": [ { "field": "firstName", "label": "Prénom", "placeholder": "Prénom" }, - { "field": "lastName", "label": "Nom de famille", "placeholder": "Nom de famille" }, { "field": "email", "label": "Courriel", "placeholder": "Courriel" }, - { "field": "jobTitle", "label": "Titre du poste", "options": [ - "Directeur artistique", - "Animateur", - "Artiste / Illustrateur", - "Designer graphique", - "Designer UI / UX", - "Développeur", - "Spécialiste marketing", - "Étudiant", - "Autre" - ]}, - { "field": "productsOfInterest", "label": "Produits d'intérêt" }, - { "field": "companySize", "label": "Taille de l'entreprise" } + { "field": "jobTitle", "label": "Titre du poste", "options": ["Directeur artistique", "Animateur", "Développeur", "Spécialiste marketing", "Étudiant", "Autre"] }, + { "field": "productsOfInterest", "label": "Produits d'intérêt" } ] } } @@ -182,13 +143,45 @@ GET /v1/events/{eventId}/configs?type=rsvp { "configId": "02510358-21b8-40ab-b693-44513051a3aa", "type": "custom-attributes", - "scopeId": "25f26faa-f8e2-4e99-b341-81a3995ef9af", + "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", "creationTime": 1712345800000, "modificationTime": 1712345800000, - "enabledAttributes": [ + "attributes": [ { "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", - "name": "primaryProductName" + "name": "primaryProductName", + "inputType": "single-select", + "enabled": true, + "values": [ + { "valueId": "a1b2c3d4-0001", "value": "Photoshop", "displayOrder": 0 }, + { "valueId": "a1b2c3d4-0002", "value": "Illustrator", "displayOrder": 1 }, + { "valueId": "a1b2c3d4-0003", "value": "Premiere Pro", "displayOrder": 2 } + ] + }, + { + "attributeId": "f75144b3-74a7-4a66-b2f5-2987d154546f", + "name": "promotionalContent", + "inputType": "multi-select", + "enabled": true, + "values": [ + { "valueId": "b2c3d4e5-0001", "value": "Blog Post", "displayOrder": 0 }, + { "valueId": "b2c3d4e5-0002", "value": "Social Media", "displayOrder": 1 }, + { "valueId": "b2c3d4e5-0003", "value": "Video", "displayOrder": 2 } + ] + }, + { + "attributeId": "c3d4e5f6-1111-2222-3333-444455556666", + "name": "splashPageKey", + "inputType": "text", + "enabled": true, + "values": [] + }, + { + "attributeId": "d4e5f6a7-1111-2222-3333-444455556666", + "name": "isVipEvent", + "inputType": "boolean", + "enabled": false, + "values": [] } ] } @@ -198,7 +191,8 @@ GET /v1/events/{eventId}/configs?type=rsvp } ``` -**Sample Response — filtered by type (`?type=rsvp`):** +### GET /v1/events/{eventId}/configs?type=rsvp — Filtered + ```json { "configs": [ @@ -207,7 +201,6 @@ GET /v1/events/{eventId}/configs?type=rsvp "type": "rsvp", "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", "rsvpFormFields": [ "...same as above..." ], - "enabledAttributes": [ "...same as above..." ], "localizations": { "...same as above..." } } ], @@ -216,123 +209,19 @@ GET /v1/events/{eventId}/configs?type=rsvp } ``` -**Sample Response — no configs found:** -```json -{ - "configs": [], - "count": 0 -} -``` - ---- - -### 2. List Configs for a Series - -``` -GET /v1/series/{seriesId}/configs -GET /v1/series/{seriesId}/configs?type=locales -``` - -Same response shape as event configs. The series → scope resolution happens server-side. - ---- - -### 3. List Custom Attributes for an Event +### POST /v1/scopes/{scopeId}/configs — Create config -``` -GET /v1/events/{eventId}/custom-attributes -``` - -**Sample Response:** +**Request:** ```json -{ - "customAttributes": [ - { - "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", - "name": "primaryProductName", - "inputType": "single-select", - "values": [ - { "valueId": "a1b2c3d4-0001", "value": "Photoshop", "displayOrder": 0 }, - { "valueId": "a1b2c3d4-0002", "value": "Illustrator", "displayOrder": 1 }, - { "valueId": "a1b2c3d4-0003", "value": "Premiere Pro", "displayOrder": 2 } - ], - "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", - "creationTime": 1712340000000, - "modificationTime": 1712340000000 - }, - { - "attributeId": "f75144b3-74a7-4a66-b2f5-2987d154546f", - "name": "promotionalContent", - "inputType": "multi-select", - "values": [ - { "valueId": "b2c3d4e5-0001", "value": "Blog Post", "displayOrder": 0 }, - { "valueId": "b2c3d4e5-0002", "value": "Social Media", "displayOrder": 1 }, - { "valueId": "b2c3d4e5-0003", "value": "Email Campaign", "displayOrder": 2 }, - { "valueId": "b2c3d4e5-0004", "value": "Video", "displayOrder": 3 } - ], - "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", - "creationTime": 1712341000000, - "modificationTime": 1712341000000 - }, - { - "attributeId": "c3d4e5f6-1111-2222-3333-444455556666", - "name": "splashPageKey", - "inputType": "text", - "values": [], - "scopeId": "25f26faa-f8e2-4e99-b341-81a3995ef9af", - "creationTime": 1712342000000, - "modificationTime": 1712342000000 - }, - { - "attributeId": "d4e5f6a7-1111-2222-3333-444455556666", - "name": "isVipEvent", - "inputType": "boolean", - "values": [], - "scopeId": "25f26faa-f8e2-4e99-b341-81a3995ef9af", - "creationTime": 1712343000000, - "modificationTime": 1712343000000 - } - ], - "count": 4 -} -``` - ---- - -### 4. List Custom Attributes for a Series - -``` -GET /v1/series/{seriesId}/custom-attributes -``` - -Same response shape as event custom attributes. - ---- - -### 5. Create a Config (Admin) - -``` -POST /v1/scopes/{scopeId}/configs -Content-Type: application/json - { "type": "rsvp", "rsvpFormFields": [ - { - "field": "firstName", - "label": "First name", - "placeholder": "First Name", - "type": "text", - "required": true - } - ], - "enabledAttributes": [ - { "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", "name": "primaryProductName" } + { "field": "firstName", "label": "First name", "type": "text", "required": true } ] } ``` -**Sample Response (201):** +**Response (201):** ```json { "configId": "7a8b9c0d-1234-5678-9abc-def012345678", @@ -341,260 +230,93 @@ Content-Type: application/json "creationTime": 1712345678000, "modificationTime": 1712345678000, "rsvpFormFields": [ - { - "field": "firstName", - "label": "First name", - "placeholder": "First Name", - "type": "text", - "required": true - } - ], - "enabledAttributes": [ - { "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", "name": "primaryProductName" } + { "field": "firstName", "label": "First name", "type": "text", "required": true } ] } ``` -**Error — duplicate type (409):** -```json -{ - "message": "A config with type 'rsvp' already exists for this scope" -} -``` +### POST — Create custom-attributes config -**Error — platform scope (400):** +**Request:** ```json { - "message": "Configs cannot be created at the platform scope level. Use org or team scopes." -} -``` - ---- - -### 6. Update a Config (Admin) - -``` -PUT /v1/scopes/{scopeId}/configs/{configId} -Content-Type: application/json - -{ - "configId": "7a8b9c0d-1234-5678-9abc-def012345678", - "type": "rsvp", - "rsvpFormFields": [ - { "field": "firstName", "label": "First name", "type": "text", "required": true }, - { "field": "email", "label": "Email", "type": "email", "required": true } - ] -} -``` - -**Sample Response (200):** -```json -{ - "configId": "7a8b9c0d-1234-5678-9abc-def012345678", - "type": "rsvp", - "modificationTime": 1712346000000, - "rsvpFormFields": [ - { "field": "firstName", "label": "First name", "type": "text", "required": true }, - { "field": "email", "label": "Email", "type": "email", "required": true } - ] -} -``` - ---- - -### 7. Delete a Config (Admin) - -``` -DELETE /v1/scopes/{scopeId}/configs/{configId} -``` - -**Response: 204 No Content** (empty body) - ---- - -### 8. Create a Custom Attribute (Admin) - -``` -POST /v1/scopes/{scopeId}/custom-attributes -Content-Type: application/json - -{ - "name": "targetAudience", - "inputType": "single-select", - "values": [ - { "value": "Enterprise" }, - { "value": "SMB" }, - { "value": "Education" }, - { "value": "Government" } + "type": "custom-attributes", + "attributes": [ + { + "name": "targetAudience", + "inputType": "single-select", + "enabled": true, + "values": [ + { "value": "Enterprise" }, + { "value": "SMB" }, + { "value": "Education" } + ] + } ] } ``` -**Sample Response (201):** +**Response (201):** ```json { - "attributeId": "e5f6a7b8-1234-5678-9abc-def012345678", - "name": "targetAudience", - "inputType": "single-select", - "values": [ - { "valueId": "f6a7b8c9-0001", "value": "Enterprise", "displayOrder": 0 }, - { "valueId": "f6a7b8c9-0002", "value": "SMB", "displayOrder": 1 }, - { "valueId": "f6a7b8c9-0003", "value": "Education", "displayOrder": 2 }, - { "valueId": "f6a7b8c9-0004", "value": "Government", "displayOrder": 3 } - ], + "configId": "e5f6a7b8-1234-5678-9abc-def012345678", + "type": "custom-attributes", "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", "creationTime": 1712350000000, - "modificationTime": 1712350000000 -} -``` - -Note: `valueId` and `displayOrder` are auto-generated by the server. You only need to send `value` in the request. - -**Error — platform scope (400):** -```json -{ - "message": "Custom attributes cannot be created at the platform scope level. Use org or team scopes." -} -``` - -**Error — missing name (400):** -```json -{ - "message": "name is required when creating a custom attribute" -} -``` - ---- - -### 9. Update a Custom Attribute (Admin) - -``` -PUT /v1/scopes/{scopeId}/custom-attributes/{attributeId} -Content-Type: application/json - -{ - "attributeId": "e5f6a7b8-1234-5678-9abc-def012345678", - "name": "targetAudience", - "inputType": "single-select", - "values": [ - { "valueId": "f6a7b8c9-0001", "value": "Enterprise", "displayOrder": 0 }, - { "valueId": "f6a7b8c9-0002", "value": "SMB", "displayOrder": 1 }, - { "valueId": "f6a7b8c9-0003", "value": "Education", "displayOrder": 2 }, - { "valueId": "f6a7b8c9-0004", "value": "Government", "displayOrder": 3 }, - { "value": "Non-Profit" } + "modificationTime": 1712350000000, + "attributes": [ + { + "name": "targetAudience", + "inputType": "single-select", + "enabled": true, + "values": [ + { "value": "Enterprise" }, + { "value": "SMB" }, + { "value": "Education" } + ] + } ] } ``` -**Sample Response (200):** -```json -{ - "attributeId": "e5f6a7b8-1234-5678-9abc-def012345678", - "name": "targetAudience", - "inputType": "single-select", - "values": [ - { "valueId": "f6a7b8c9-0001", "value": "Enterprise", "displayOrder": 0 }, - { "valueId": "f6a7b8c9-0002", "value": "SMB", "displayOrder": 1 }, - { "valueId": "f6a7b8c9-0003", "value": "Education", "displayOrder": 2 }, - { "valueId": "f6a7b8c9-0004", "value": "Government", "displayOrder": 3 }, - { "valueId": "a7b8c9d0-0005", "value": "Non-Profit", "displayOrder": 4 } - ], - "modificationTime": 1712351000000 -} -``` - -Note: New values without a `valueId` get one auto-generated. Existing values with `valueId` are preserved. +Note: `attributeId` and `valueId` should be generated client-side (UUID) before sending. The server stores them as-is via `additionalProperties: true`. ---- +### PUT /v1/scopes/{scopeId}/configs/{configId} — Update -### 10. Delete a Custom Attribute (Admin) +**Request/Response:** Same shape as create, returns updated config with new `modificationTime`. -``` -DELETE /v1/scopes/{scopeId}/custom-attributes/{attributeId} -``` +### DELETE /v1/scopes/{scopeId}/configs/{configId} **Response: 204 No Content** (empty body) --- -### 11. Get a Single Config - -``` -GET /v1/scopes/{scopeId}/configs/{configId} -``` - -**Sample Response (200):** -```json -{ - "configId": "5bff274f-4f58-419e-8729-9096fac8b737", - "type": "rsvp", - "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", - "creationTime": 1712345678000, - "modificationTime": 1712345678000, - "rsvpFormFields": [ ... ], - "enabledAttributes": [ ... ], - "localizations": { - "fr-FR": { "rsvpFormFields": [ ... ] } - } -} -``` - ---- - -### 12. Get a Single Custom Attribute - -``` -GET /v1/scopes/{scopeId}/custom-attributes/{attributeId} -``` +## Error Responses -**Sample Response (200):** ```json -{ - "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", - "name": "primaryProductName", - "inputType": "single-select", - "values": [ - { "valueId": "a1b2c3d4-0001", "value": "Photoshop", "displayOrder": 0 }, - { "valueId": "a1b2c3d4-0002", "value": "Illustrator", "displayOrder": 1 }, - { "valueId": "a1b2c3d4-0003", "value": "Premiere Pro", "displayOrder": 2 } - ], - "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", - "creationTime": 1712340000000, - "modificationTime": 1712340000000 -} -``` - ---- - -### 13. List Configs for a Scope (Admin — with hierarchy merge) - -``` -GET /v1/scopes/{scopeId}/configs -GET /v1/scopes/{scopeId}/configs?type=rsvp +{ "message": "Human-readable error description" } ``` -Same response shape as event/series configs. Includes configs inherited from parent scopes. Each config's `scopeId` tells you where it originated. - ---- - -## Required Headers - -``` -Authorization: Bearer {ims_token} -x-adobe-esp-group-id: {group_id} -Content-Type: application/json ← only for POST/PUT -``` +| Status | Meaning | Example | +|--------|---------|---------| +| 200 | Success | — | +| 201 | Created | — | +| 204 | Deleted (empty body) | — | +| 400 | Bad request | `"type is required when creating a config"` | +| 400 | Platform restriction | `"Configs cannot be created at the platform scope level."` | +| 403 | Forbidden | `"Insufficient permissions"` | +| 404 | Not found | `"Config not found"` / `"Scope not found"` | +| 409 | Duplicate | `"A config with type 'rsvp' already exists for this scope"` | --- ## Integration Patterns -### 1. Loading RSVP Form Fields for Event Registration +### 1. Loading RSVP Form Fields ```javascript -const res = await fetch(`/v1/events/${eventId}/configs?type=rsvp`, { headers }); -const { configs } = await res.json(); -const rsvpConfig = configs[0]; // one config per type +const { configs } = await fetch(`/v1/events/${eventId}/configs?type=rsvp`, { headers }).then(r => r.json()); +const rsvpConfig = configs[0]; if (rsvpConfig) { const formFields = rsvpConfig.rsvpFormFields || []; @@ -604,88 +326,102 @@ if (rsvpConfig) { // field.label → "Job title" (display text) // field.placeholder → "Job Title" // field.type → "select" | "multi-select" | "text" | "email" | "phone" - // field.displayAs → "dropdown" | "radio" | "checkbox" (for select types) + // field.displayAs → "dropdown" | "radio" | "checkbox" // field.required → true/false - // field.options → ["Option A", "Option B", ...] (for select types) + // field.options → ["Option A", "Option B", ...] // field.default → default value // field.rules → "full-width" etc. }); } ``` -### 2. Loading Locales for Event Creation +### 2. Handling RSVP Localizations + +```javascript +const rsvpConfig = configs[0]; +const userLocale = event.defaultLocale; // e.g. "fr-FR" + +const baseFields = rsvpConfig.rsvpFormFields || []; +const localeOverrides = rsvpConfig.localizations?.[userLocale]?.rsvpFormFields || []; + +const localizedFields = baseFields.map(field => { + const override = localeOverrides.find(o => o.field === field.field); + return { + ...field, + label: override?.label || field.label, + placeholder: override?.placeholder || field.placeholder, + options: override?.options || field.options + }; +}); +``` + +### 3. Loading Locales ```javascript -const res = await fetch(`/v1/series/${seriesId}/configs?type=locales`, { headers }); -const { configs } = await res.json(); +const { configs } = await fetch(`/v1/series/${seriesId}/configs?type=locales`, { headers }).then(r => r.json()); const localesConfig = configs[0]; if (localesConfig) { const localeNames = localesConfig.localeNames; // { "en-US": "English, United States", "fr-FR": "French, France" } - // → populate locale dropdown const localeUrlCodes = localesConfig.localeUrlCodes; // { "en-US": "", "fr-FR": "fr" } - // → used for building detail page paths } ``` -### 3. Loading Custom Attributes for Event Form +### 4. Loading Custom Attributes ```javascript -const res = await fetch(`/v1/events/${eventId}/custom-attributes`, { headers }); -const { customAttributes } = await res.json(); +const { configs } = await fetch(`/v1/events/${eventId}/configs?type=custom-attributes`, { headers }).then(r => r.json()); +const customAttrsConfig = configs[0]; + +// Filter to only enabled attributes +const enabledAttributes = (customAttrsConfig?.attributes || []).filter(a => a.enabled !== false); + +enabledAttributes.forEach(attr => { + // attr.attributeId → unique ID + // attr.name → "Digital Agenda Track" + // attr.inputType → "text" | "single-select" | "multi-select" | "boolean" + // attr.values → [{ valueId, value, displayOrder }] -customAttributes.forEach(attr => { switch (attr.inputType) { - case 'text': - // render text input - break; - case 'boolean': - // render checkbox/toggle - break; - case 'single-select': - // render dropdown or radio group using attr.values - // each value: { valueId, value, displayOrder } - break; - case 'multi-select': - // render multi-select or checkbox group using attr.values - break; + case 'text': // render text input + case 'boolean': // render checkbox/toggle + case 'single-select': // render dropdown with attr.values + case 'multi-select': // render multi-select with attr.values } }); ``` -### 4. Saving Custom Attribute Values on an Event +### 5. Saving Custom Attribute Values on Event ```javascript const customAttributes = []; -// Multi-select: one entry per selected value -customAttributes.push( - { - attributeId: "52ff35ec-...", - attribute: "primaryProductName", // denormalized name - valueId: "a1b2c3d4-0001", - value: "Photoshop", // denormalized value - displayOrder: 0 - } -); +// Single-select: one entry +customAttributes.push({ + attributeId: "52ff35ec-...", + attribute: "primaryProductName", + valueId: "a1b2c3d4-0001", + value: "Photoshop", + displayOrder: 0 +}); -// Multi-select with multiple selections +// Multi-select: multiple entries with same attributeId customAttributes.push( { attributeId: "f75144b3-...", attribute: "promotionalContent", valueId: "b2c3d4e5-0001", value: "Blog Post", displayOrder: 0 }, - { attributeId: "f75144b3-...", attribute: "promotionalContent", valueId: "b2c3d4e5-0004", value: "Video", displayOrder: 3 } + { attributeId: "f75144b3-...", attribute: "promotionalContent", valueId: "b2c3d4e5-0003", value: "Video", displayOrder: 2 } ); -// Text attribute +// Text customAttributes.push({ attributeId: "c3d4e5f6-...", attribute: "splashPageKey", value: "summit-2026-splash" }); -// Boolean attribute +// Boolean customAttributes.push({ attributeId: "d4e5f6a7-...", attribute: "isVipEvent", @@ -700,70 +436,68 @@ await fetch(`/v1/events/${eventId}`, { }); ``` -### 5. Using Config-Linked Custom Attributes +### 6. Loading All Configs at Once ```javascript -// Load RSVP config + all custom attributes in parallel -const [configRes, attrRes] = await Promise.all([ - fetch(`/v1/events/${eventId}/configs?type=rsvp`, { headers }), - fetch(`/v1/events/${eventId}/custom-attributes`, { headers }) +// Parallel load: all configs + specific types +const [allRes, rsvpRes] = await Promise.all([ + fetch(`/v1/events/${eventId}/configs`, { headers }), + fetch(`/v1/events/${eventId}/configs?type=rsvp`, { headers }) ]); -const { configs } = await configRes.json(); -const { customAttributes } = await attrRes.json(); -const rsvpConfig = configs[0]; -// Filter to only attributes enabled for this config -const enabledIds = new Set((rsvpConfig?.enabledAttributes || []).map(a => a.attributeId)); -const rsvpAttributes = customAttributes.filter(a => enabledIds.has(a.attributeId)); +const { configs: allConfigs } = await allRes.json(); +const { configs: rsvpConfigs } = await rsvpRes.json(); -// Render: RSVP form fields + rsvpAttributes together in the registration form +// Or load all and filter client-side +const rsvpConfig = allConfigs.find(c => c.type === 'rsvp'); +const localesConfig = allConfigs.find(c => c.type === 'locales'); +const customAttrsConfig = allConfigs.find(c => c.type === 'custom-attributes'); ``` -### 6. Handling RSVP Localizations +--- -```javascript -const rsvpConfig = configs[0]; -const userLocale = event.defaultLocale; // e.g. "fr-FR" +## Hierarchy Behavior -const baseFields = rsvpConfig.rsvpFormFields || []; -const localeOverrides = rsvpConfig.localizations?.[userLocale]?.rsvpFormFields || []; +| Merge Strategy | Behavior | +|---------------|----------| +| **Full replace** per type | If team scope has a `rsvp` config, it completely replaces the org's `rsvp` config | -// Merge: localized values override base, fall back to base if no translation -const localizedFields = baseFields.map(field => { - const override = localeOverrides.find(o => o.field === field.field); - return { - ...field, - label: override?.label || field.label, - placeholder: override?.placeholder || field.placeholder, - options: override?.options || field.options - }; -}); -``` +Each config in the response includes a `scopeId` field indicating where it was originally defined. If `scopeId` differs from the queried scope, it's inherited from a parent. ---- +### Overriding Inherited Configs -## Hierarchy Behavior +To override an inherited config at a child scope (e.g., team wants to disable some custom attributes from the org): -| Resource | Merge Strategy | Example | -|----------|---------------|---------| -| Configs | **Full replace** per type | Team's `rsvp` config fully replaces org's `rsvp` config | -| Custom Attributes | **Accumulate** | Team sees own attributes + all org attributes | +1. Read the inherited config from the parent scope +2. Copy its data (strip `configId`, `scopeId`, `creationTime`, `modificationTime`) +3. Modify as needed (e.g., set `enabled: false` on unwanted attributes) +4. Create a new config at the child scope with the same `type` -Each item in the response includes a `scopeId` field indicating where it was originally defined. +```javascript +// Override inherited custom-attributes config at team scope +const { configs } = await fetch(`/v1/scopes/${teamScopeId}/configs?type=custom-attributes`, { headers }).then(r => r.json()); +const inherited = configs[0]; + +// Copy and modify — disable one attribute +const override = { + type: 'custom-attributes', + attributes: inherited.attributes.map(attr => + attr.name === 'unwantedAttribute' ? { ...attr, enabled: false } : attr + ) +}; + +// Create at team scope — this replaces the inherited config +await fetch(`/v1/scopes/${teamScopeId}/configs`, { + method: 'POST', + headers, + body: JSON.stringify(override) +}); +``` --- ## Input Type Reference -### Custom Attribute `inputType` Values - -| Value | Render As | Values Array | -|-------|-----------|-------------| -| `text` | Text input | Not used | -| `boolean` | Checkbox/toggle | Not used | -| `single-select` | Dropdown or radio buttons | Required — list of options | -| `multi-select` | Multi-dropdown or checkboxes | Required — list of options | - ### RSVP Form Field `type` Values | Value | Render As | @@ -783,35 +517,23 @@ Each item in the response includes a `scopeId` field indicating where it was ori | `dropdown` | `multi-select` | Multi-select dropdown | | `checkbox` | `multi-select` | Checkbox group | ---- - -## Error Responses - -All errors return JSON with a `message` field: - -```json -{ "message": "Human-readable error description" } -``` +### Custom Attribute `inputType` Values -| Status | Meaning | Example Message | -|--------|---------|-----------------| -| 200 | Success | — | -| 201 | Created | — | -| 204 | Deleted (empty body) | — | -| 400 | Bad request | `"type is required when creating a config"` | -| 400 | Platform restriction | `"Configs cannot be created at the platform scope level. Use org or team scopes."` | -| 403 | Forbidden | `"Insufficient permissions"` | -| 404 | Not found | `"Config not found"` / `"Scope not found"` | -| 409 | Duplicate | `"A config with type 'rsvp' already exists for this scope"` | -| 500 | Server error | `"Internal server error"` | +| Value | Render As | Values Array | +|-------|-----------|-------------| +| `text` | Text input | Not used | +| `boolean` | Checkbox/toggle | Not used | +| `single-select` | Dropdown | Required — list of options | +| `multi-select` | Multi-select | Required — list of options | --- ## Notes -- Configs and custom attributes **cannot** be created at the platform scope level — only org and team scopes -- The `?type=` query parameter filters configs by type; omit it to get all configs -- Custom attribute values on events are **denormalized** — both IDs and display names are stored for fast reads -- RSVP form field labels and select options support **localization** via the `localizations` object on the config -- New custom attribute values without a `valueId` get one auto-generated on create/update -- `displayOrder` is auto-assigned by array index if not provided \ No newline at end of file +- Configs **cannot** be created at the platform scope level — only org and team +- The `?type=` query parameter filters configs; omit it to get all configs +- Custom attribute values on events are **denormalized** — both IDs and display names stored +- RSVP labels and options support **localization** via the `localizations` object +- Custom attributes with `enabled: false` should be hidden from the events console +- One config per type per scope — duplicates return 409 +- Inherited configs can be **overridden** at child scopes by creating a config with the same type — the child's config fully replaces the parent's \ No newline at end of file diff --git a/web-src/src/pages/ConfigManagement/ConfigManagement.tsx b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx index 2da2c74..381d75d 100644 --- a/web-src/src/pages/ConfigManagement/ConfigManagement.tsx +++ b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx @@ -47,10 +47,10 @@ import type { ScopeConfig, RsvpScopeConfig, LocalesScopeConfig, + CustomAttributesScopeConfig, RsvpFormField, RsvpFormFieldLocaleOverride, - EnabledAttributeRef, - CustomAttribute, + CustomAttributeConfig, CustomAttributeValue, CustomAttributeInputType, RsvpFieldType, @@ -58,6 +58,7 @@ import type { } from '../../types/configApi' import { ResourceDashboardLayout, BlurredLoadingOverlay } from '../../components/shared' import { useHasPermission } from '../../hooks/useHasPermission' +import { generateUUID } from '../../services/requestHelpers' import { SUPPORTED_SPEAKER_LOCALES, SPEAKER_LOCALE_LABELS } from '../../config/localeMapping' interface ConfigManagementProps { @@ -134,8 +135,6 @@ export const ConfigManagement: React.FC = () => { const [configs, setConfigs] = useState([]) const [isLoadingConfigs, setIsLoadingConfigs] = useState(false) - const [customAttributes, setCustomAttributes] = useState([]) - const [isLoadingAttributes, setIsLoadingAttributes] = useState(false) const [activeTab, setActiveTab] = useState('rsvp') // ============================================================================ @@ -145,7 +144,6 @@ export const ConfigManagement: React.FC = () => { const [isRsvpFormOpen, setIsRsvpFormOpen] = useState(false) const [editingRsvpConfig, setEditingRsvpConfig] = useState(null) const [rsvpFormFields, setRsvpFormFields] = useState([]) - const [rsvpEnabledAttributes, setRsvpEnabledAttributes] = useState([]) const [rsvpLocalizations, setRsvpLocalizations] = useState>({}) const [rsvpLocaleEditing, setRsvpLocaleEditing] = useState(null) const [rsvpConfigToDelete, setRsvpConfigToDelete] = useState(null) @@ -167,11 +165,12 @@ export const ConfigManagement: React.FC = () => { // ============================================================================ const [isAttrFormOpen, setIsAttrFormOpen] = useState(false) - const [editingAttr, setEditingAttr] = useState(null) + const [editingAttr, setEditingAttr] = useState(null) const [attrFormName, setAttrFormName] = useState('') const [attrFormInputType, setAttrFormInputType] = useState('text') const [attrFormValues, setAttrFormValues] = useState([]) - const [attrToDelete, setAttrToDelete] = useState(null) + const [attrFormEnabled, setAttrFormEnabled] = useState(true) + const [attrToDelete, setAttrToDelete] = useState(null) // Expandable state for attributes table const [expandedAttrKeys, setExpandedAttrKeys] = useState>(new Set()) @@ -218,6 +217,14 @@ export const ConfigManagement: React.FC = () => { () => configs.find((c): c is LocalesScopeConfig => c.type === 'locales') || null, [configs] ) + const customAttrsConfig = useMemo( + () => configs.find((c): c is CustomAttributesScopeConfig => c.type === 'custom-attributes') || null, + [configs] + ) + const customAttributes = useMemo( + () => customAttrsConfig?.attributes || [], + [customAttrsConfig] + ) // Available locales for RSVP localization (from sibling locales config or fallback) const availableLocales = useMemo(() => { if (localesConfig) { @@ -261,25 +268,8 @@ export const ConfigManagement: React.FC = () => { } }, [apiService, selectedScopeId]) - const loadCustomAttributes = useCallback(async () => { - if (!selectedScopeId) { - setCustomAttributes([]) - return - } - setIsLoadingAttributes(true) - try { - const result = await apiService.getCustomAttributesForScope(selectedScopeId) - if (!('error' in result)) setCustomAttributes(result) - } catch { - // Errors handled silently — consumer shows empty state - } finally { - setIsLoadingAttributes(false) - } - }, [apiService, selectedScopeId]) - useEffect(() => { loadScopes() }, [loadScopes]) useEffect(() => { loadConfigs() }, [loadConfigs]) - useEffect(() => { loadCustomAttributes() }, [loadCustomAttributes]) // Clear state on scope change useEffect(() => { @@ -302,7 +292,6 @@ export const ConfigManagement: React.FC = () => { const openRsvpCreate = useCallback(() => { setEditingRsvpConfig(null) setRsvpFormFields([createEmptyRsvpField()]) - setRsvpEnabledAttributes([]) setRsvpLocalizations({}) setRsvpLocaleEditing(null) setIsRsvpFormOpen(true) @@ -311,7 +300,6 @@ export const ConfigManagement: React.FC = () => { const openRsvpEdit = useCallback((config: RsvpScopeConfig) => { setEditingRsvpConfig(config) setRsvpFormFields([...config.rsvpFormFields]) - setRsvpEnabledAttributes([...(config.enabledAttributes || [])]) setRsvpLocalizations(config.localizations ? JSON.parse(JSON.stringify(config.localizations)) : {}) setRsvpLocaleEditing(null) setIsRsvpFormOpen(true) @@ -331,7 +319,6 @@ export const ConfigManagement: React.FC = () => { const result = await apiService.updateConfig(selectedScopeId, editingRsvpConfig.configId, { ...editingRsvpConfig, rsvpFormFields: validFields, - enabledAttributes: rsvpEnabledAttributes, localizations: rsvpLocalizations, }) if ('error' in result) { @@ -346,7 +333,6 @@ export const ConfigManagement: React.FC = () => { const result = await apiService.createConfig(selectedScopeId, { type: 'rsvp', rsvpFormFields: validFields, - enabledAttributes: rsvpEnabledAttributes, localizations: rsvpLocalizations, }) if ('error' in result) { @@ -366,7 +352,7 @@ export const ConfigManagement: React.FC = () => { } finally { setIsSaving(false) } - }, [selectedScopeId, rsvpFormFields, rsvpEnabledAttributes, rsvpLocalizations, editingRsvpConfig, apiService, toast, loadConfigs]) + }, [selectedScopeId, rsvpFormFields, rsvpLocalizations, editingRsvpConfig, apiService, toast, loadConfigs]) const handleDeleteConfig = useCallback(async (config: ScopeConfig) => { if (!selectedScopeId) return @@ -475,14 +461,16 @@ export const ConfigManagement: React.FC = () => { setAttrFormName('') setAttrFormInputType('text') setAttrFormValues([]) + setAttrFormEnabled(true) setIsAttrFormOpen(true) }, []) - const openAttrEdit = useCallback((attr: CustomAttribute) => { + const openAttrEdit = useCallback((attr: CustomAttributeConfig) => { setEditingAttr(attr) setAttrFormName(attr.name) setAttrFormInputType(attr.inputType) setAttrFormValues([...attr.values]) + setAttrFormEnabled(attr.enabled) setIsAttrFormOpen(true) }, []) @@ -492,64 +480,94 @@ export const ConfigManagement: React.FC = () => { return } + const newAttr: CustomAttributeConfig = { + attributeId: editingAttr?.attributeId || generateUUID(), + name: attrFormName.trim(), + inputType: attrFormInputType, + enabled: attrFormEnabled, + values: attrFormValues + .filter(v => v.value.trim()) + .map((v, i) => ({ + valueId: v.valueId || generateUUID(), + value: v.value.trim(), + displayOrder: i, + })), + } + setIsSaving(true) try { - if (editingAttr) { - const result = await apiService.updateCustomAttribute(selectedScopeId, editingAttr.attributeId, { - attributeId: editingAttr.attributeId, - name: attrFormName.trim(), - inputType: attrFormInputType, - values: attrFormValues, + if (customAttrsConfig) { + const updatedAttributes = editingAttr + ? customAttrsConfig.attributes.map(a => + a.attributeId === editingAttr.attributeId ? newAttr : a + ) + : [...customAttrsConfig.attributes, newAttr] + + const result = await apiService.updateConfig(selectedScopeId, customAttrsConfig.configId, { + ...customAttrsConfig, + attributes: updatedAttributes, }) if ('error' in result) { const status = (result as { status: number }).status toast.error(status === 409 - ? 'This attribute was modified by someone else. Refresh and try again.' - : 'Failed to update custom attribute') + ? 'This config was modified by someone else. Refresh and try again.' + : `Failed to ${editingAttr ? 'update' : 'create'} custom attribute`) return } - toast.success('Custom attribute updated') } else { - const result = await apiService.createCustomAttribute(selectedScopeId, { - name: attrFormName.trim(), - inputType: attrFormInputType, - values: attrFormValues.filter(v => v.value.trim()).map(v => ({ value: v.value.trim() })), + const result = await apiService.createConfig(selectedScopeId, { + type: 'custom-attributes', + attributes: [newAttr], }) if ('error' in result) { toast.error('Failed to create custom attribute') return } - toast.success('Custom attribute created') } + toast.success(`Custom attribute ${editingAttr ? 'updated' : 'created'}`) setIsAttrFormOpen(false) setIsSaving(false) - await loadCustomAttributes() + await loadConfigs() } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to save custom attribute') } finally { setIsSaving(false) } - }, [selectedScopeId, attrFormName, attrFormInputType, attrFormValues, editingAttr, apiService, toast, loadCustomAttributes]) + }, [selectedScopeId, attrFormName, attrFormInputType, attrFormValues, attrFormEnabled, editingAttr, customAttrsConfig, apiService, toast, loadConfigs]) - const handleDeleteAttr = useCallback(async (attr: CustomAttribute) => { - if (!selectedScopeId) return + const handleDeleteAttr = useCallback(async (attr: CustomAttributeConfig) => { + if (!selectedScopeId || !customAttrsConfig) return setIsSaving(true) try { - const result = await apiService.deleteCustomAttribute(selectedScopeId, attr.attributeId) - if ('error' in result) { - toast.error('Failed to delete custom attribute') - return + const remaining = customAttrsConfig.attributes.filter(a => a.attributeId !== attr.attributeId) + + if (remaining.length === 0) { + const result = await apiService.deleteConfig(selectedScopeId, customAttrsConfig.configId) + if ('error' in result) { + toast.error('Failed to delete custom attribute') + return + } + } else { + const result = await apiService.updateConfig(selectedScopeId, customAttrsConfig.configId, { + ...customAttrsConfig, + attributes: remaining, + }) + if ('error' in result) { + toast.error('Failed to delete custom attribute') + return + } } + toast.success('Custom attribute deleted') setAttrToDelete(null) setIsSaving(false) - await loadCustomAttributes() + await loadConfigs() } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to delete custom attribute') } finally { setIsSaving(false) } - }, [apiService, selectedScopeId, toast, loadCustomAttributes]) + }, [apiService, selectedScopeId, customAttrsConfig, toast, loadConfigs]) // ============================================================================ // RSVP FIELD HELPERS @@ -733,14 +751,27 @@ export const ConfigManagement: React.FC = () => { // CUSTOM ATTRIBUTES TABLE // ============================================================================ + const isOwnAttrsConfig = customAttrsConfig?.scopeId === selectedScopeId + const attrColumns = useMemo(() => [ { key: 'name', name: 'NAME', width: 200, sortable: true }, + { + key: 'enabled', + name: 'ENABLED', + width: 100, + sortable: true, + render: (item: CustomAttributeConfig) => ( + + {item.enabled ? 'Yes' : 'No'} + + ), + }, { key: 'inputType', name: 'INPUT TYPE', width: 140, sortable: true, - render: (item: CustomAttribute) => ( + render: (item: CustomAttributeConfig) => ( {item.inputType} ), }, @@ -749,7 +780,7 @@ export const ConfigManagement: React.FC = () => { name: 'VALUES', width: 100, sortable: false, - render: (item: CustomAttribute) => ( + render: (item: CustomAttributeConfig) => ( {item.values.length > 0 ? `${item.values.length} values` : '-'} ), }, @@ -758,14 +789,17 @@ export const ConfigManagement: React.FC = () => { name: 'SCOPE', width: 120, sortable: false, - render: (item: CustomAttribute) => { - const scope = scopes.find(s => s.scopeId === item.scopeId) + render: () => { + const configScopeId = customAttrsConfig?.scopeId + const scope = configScopeId ? scopes.find(s => s.scopeId === configScopeId) : null return scope ? ( {scope.name} - ) : ( + ) : configScopeId ? ( - {item.scopeId.substring(0, 8)}... + {configScopeId.substring(0, 8)}... + ) : ( + - ) }, }, @@ -774,27 +808,24 @@ export const ConfigManagement: React.FC = () => { name: 'ACTIONS', width: 100, sortable: false, - render: (item: CustomAttribute) => { - const isOwnScope = item.scopeId === selectedScopeId - return ( -
- {canWriteConfig && isOwnScope && ( - openAttrEdit(item)}> - - - )} - {canDeleteConfig && isOwnScope && ( - setAttrToDelete(item)}> - - - )} -
- ) - }, + render: (item: CustomAttributeConfig) => ( +
+ {canWriteConfig && isOwnAttrsConfig && ( + openAttrEdit(item)}> + + + )} + {canDeleteConfig && isOwnAttrsConfig && ( + setAttrToDelete(item)}> + + + )} +
+ ), }, - ], [scopes, selectedScopeId, canWriteConfig, canDeleteConfig, openAttrEdit]) + ], [scopes, customAttrsConfig, isOwnAttrsConfig, canWriteConfig, canDeleteConfig, openAttrEdit]) - const renderAttrExpandedContent = useCallback((item: CustomAttribute) => { + const renderAttrExpandedContent = useCallback((item: CustomAttributeConfig) => { if (item.values.length === 0) { return ( @@ -822,13 +853,13 @@ export const ConfigManagement: React.FC = () => { isRsvpFormOpen || isLocalesFormOpen || isAttrFormOpen || rsvpConfigToDelete != null || localesToDelete != null || attrToDelete != null return { - loadingOverlayVisible: (isLoadingScopes || isLoadingConfigs || isLoadingAttributes) && !isSaving, + loadingOverlayVisible: (isLoadingScopes || isLoadingConfigs) && !isSaving, savingOverlayVisible: isSaving && !isBlockingDialogOpen, } }, [ isRsvpFormOpen, isLocalesFormOpen, isAttrFormOpen, rsvpConfigToDelete, localesToDelete, attrToDelete, - isLoadingScopes, isLoadingConfigs, isLoadingAttributes, + isLoadingScopes, isLoadingConfigs, isSaving, ]) @@ -1057,7 +1088,7 @@ export const ConfigManagement: React.FC = () => { Create Attribute ) : undefined} - onRefresh={loadCustomAttributes} + onRefresh={loadConfigs} emptyStateIllustration={} emptyStateTitle="No Custom Attributes" emptyStateDescription="Create custom attributes to add additional fields to events" @@ -1227,33 +1258,6 @@ export const ConfigManagement: React.FC = () => {
- {/* Enabled Attributes */} - {customAttributes.length > 0 && ( -
- Enabled Custom Attributes -
- {customAttributes.map(attr => { - const isEnabled = rsvpEnabledAttributes.some(ea => ea.attributeId === attr.attributeId) - return ( - { - if (checked) { - setRsvpEnabledAttributes(prev => [...prev, { attributeId: attr.attributeId, name: attr.name }]) - } else { - setRsvpEnabledAttributes(prev => prev.filter(ea => ea.attributeId !== attr.attributeId)) - } - }} - > - {attr.name} ({attr.inputType}) - - ) - })} -
-
- )} - {/* Localizations */}
Localizations @@ -1450,6 +1454,9 @@ export const ConfigManagement: React.FC = () => { isRequired autoFocus /> + + Enabled + { - validateString(scopeId, 'scope ID') - return this.fetchAllPages({ - service: 'esp', - baseEndpoint: `/v1/scopes/${encodeURIComponent(scopeId)}/custom-attributes`, - listKey: 'customAttributes', - operationName: 'getCustomAttributesForScope' - }) - } - - async getCustomAttributeById(scopeId: string, attributeId: string): Promise { - validateString(scopeId, 'scope ID') - validateString(attributeId, 'attribute ID') - return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/custom-attributes/${encodeURIComponent(attributeId)}`, 'GET', undefined, { - operationName: 'getCustomAttributeById', - shouldReturnFullResponse: true - }) - } - - async createCustomAttribute(scopeId: string, data: CustomAttributeCreateBody): Promise { - validateString(scopeId, 'scope ID') - validateObject(data, 'custom attribute create body') - return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/custom-attributes`, 'POST', data, { - operationName: 'createCustomAttribute', - shouldReturnFullResponse: true - }) - } - - async updateCustomAttribute(scopeId: string, attributeId: string, data: CustomAttributeUpdateBody): Promise { - validateString(scopeId, 'scope ID') - validateString(attributeId, 'attribute ID') - validateObject(data, 'custom attribute update body') - return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/custom-attributes/${encodeURIComponent(attributeId)}`, 'PUT', data, { - operationName: 'updateCustomAttribute', - shouldReturnFullResponse: true - }) - } - - async deleteCustomAttribute(scopeId: string, attributeId: string): Promise { - validateString(scopeId, 'scope ID') - validateString(attributeId, 'attribute ID') - return this.callExternalApi('esp', `/v1/scopes/${encodeURIComponent(scopeId)}/custom-attributes/${encodeURIComponent(attributeId)}`, 'DELETE', undefined, { - operationName: 'deleteCustomAttribute' - }) - } - // --- Convenience Endpoints (resolved configs for events/series) --- async getEventConfigs(eventId: string, type?: ConfigType): Promise { @@ -2460,26 +2409,6 @@ class ApiService { operationName: 'getSeriesConfigs' }) } - - async getEventCustomAttributes(eventId: string): Promise { - validateString(eventId, 'event ID') - return this.fetchAllPages({ - service: 'esp', - baseEndpoint: `/v1/events/${encodeURIComponent(eventId)}/custom-attributes`, - listKey: 'customAttributes', - operationName: 'getEventCustomAttributes' - }) - } - - async getSeriesCustomAttributes(seriesId: string): Promise { - validateString(seriesId, 'series ID') - return this.fetchAllPages({ - service: 'esp', - baseEndpoint: `/v1/series/${encodeURIComponent(seriesId)}/custom-attributes`, - listKey: 'customAttributes', - operationName: 'getSeriesCustomAttributes' - }) - } } // ============================================================================ @@ -2642,16 +2571,10 @@ export const cachedApi = { // === CONFIGS (GET Operations - Cached) === getConfigsForScope: (scopeId: string, type?: ConfigType) => apiCache.get((id: string, t?: ConfigType) => apiService.getConfigsForScope(id, t), scopeId, type), - getCustomAttributesForScope: (scopeId: string) => - apiCache.get((id: string) => apiService.getCustomAttributesForScope(id), scopeId), getEventConfigs: (eventId: string, type?: ConfigType) => apiCache.get((id: string, t?: ConfigType) => apiService.getEventConfigs(id, t), eventId, type), getSeriesConfigs: (seriesId: string, type?: ConfigType) => apiCache.get((id: string, t?: ConfigType) => apiService.getSeriesConfigs(id, t), seriesId, type), - getEventCustomAttributes: (eventId: string) => - apiCache.get((id: string) => apiService.getEventCustomAttributes(id), eventId), - getSeriesCustomAttributes: (seriesId: string) => - apiCache.get((id: string) => apiService.getSeriesCustomAttributes(id), seriesId), // === MUTATIONS (with cache invalidation) === @@ -2789,28 +2712,6 @@ export const cachedApi = { return result }, - // Custom Attribute Mutations - async createCustomAttribute(scopeId: string, data: CustomAttributeCreateBody) { - const result = await apiService.createCustomAttribute(scopeId, data) - apiCache.invalidate(scopeId) - apiCache.invalidate('getCustomAttributesForScope') - return result - }, - async updateCustomAttribute(scopeId: string, attributeId: string, data: CustomAttributeUpdateBody) { - const result = await apiService.updateCustomAttribute(scopeId, attributeId, data) - apiCache.invalidate(scopeId) - apiCache.invalidate(attributeId) - apiCache.invalidate('getCustomAttributesForScope') - return result - }, - async deleteCustomAttribute(scopeId: string, attributeId: string) { - const result = await apiService.deleteCustomAttribute(scopeId, attributeId) - apiCache.invalidate(scopeId) - apiCache.invalidate(attributeId) - apiCache.invalidate('getCustomAttributesForScope') - return result - }, - // === UTILITY METHODS === /** diff --git a/web-src/src/types/configApi.ts b/web-src/src/types/configApi.ts index 54e5999..b075dba 100644 --- a/web-src/src/types/configApi.ts +++ b/web-src/src/types/configApi.ts @@ -42,15 +42,6 @@ export interface RsvpFormFieldLocaleOverride { options?: string[] } -// ============================================================================ -// Enabled Attribute Reference -// ============================================================================ - -export interface EnabledAttributeRef { - attributeId: string - name: string -} - // ============================================================================ // Scope Config Models // ============================================================================ @@ -66,7 +57,6 @@ interface ScopeConfigBase { export interface RsvpScopeConfig extends ScopeConfigBase { type: 'rsvp' rsvpFormFields: RsvpFormField[] - enabledAttributes: EnabledAttributeRef[] localizations: Record } @@ -78,7 +68,7 @@ export interface LocalesScopeConfig extends ScopeConfigBase { export interface CustomAttributesScopeConfig extends ScopeConfigBase { type: 'custom-attributes' - enabledAttributes: EnabledAttributeRef[] + attributes: CustomAttributeConfig[] } export type ScopeConfig = RsvpScopeConfig | LocalesScopeConfig | CustomAttributesScopeConfig @@ -93,14 +83,12 @@ export interface CustomAttributeValue { displayOrder: number } -export interface CustomAttribute { +export interface CustomAttributeConfig { attributeId: string name: string inputType: CustomAttributeInputType + enabled: boolean values: CustomAttributeValue[] - scopeId: string - creationTime: number - modificationTime: number } // ============================================================================ @@ -110,7 +98,6 @@ export interface CustomAttribute { export interface RsvpConfigCreateBody { type: 'rsvp' rsvpFormFields: RsvpFormField[] - enabledAttributes?: EnabledAttributeRef[] localizations?: Record } @@ -122,7 +109,7 @@ export interface LocalesConfigCreateBody { export interface CustomAttributesConfigCreateBody { type: 'custom-attributes' - enabledAttributes: EnabledAttributeRef[] + attributes: CustomAttributeConfig[] } export type ConfigCreateBody = @@ -132,19 +119,6 @@ export type ConfigCreateBody = export type ConfigUpdateBody = ScopeConfig -export interface CustomAttributeCreateBody { - name: string - inputType: CustomAttributeInputType - values?: Array<{ value: string }> -} - -export interface CustomAttributeUpdateBody { - attributeId: string - name: string - inputType: CustomAttributeInputType - values: CustomAttributeValue[] -} - // ============================================================================ // Response Envelopes // ============================================================================ @@ -154,9 +128,3 @@ export interface ConfigListResponse { count: number nextPageToken?: string | null } - -export interface CustomAttributeListResponse { - customAttributes: CustomAttribute[] - count: number - nextPageToken?: string | null -} From 034129db71bb17735516cfcd7ee6030c1daa424e Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Wed, 8 Apr 2026 16:41:56 -0700 Subject: [PATCH 04/23] feat(config): ConfigManagement UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collapsible RSVP field cards in Create/Edit dialog (only select/multi-select; non-option fields always expanded) - Locale switcher in RSVP toolbar; all localizable fields reflect selected locale; per-field edit dialog handles locale-mode saves (base + override in one API call); Localizations section removed from RSVP dialog - RSVP expanded options: replace numbered text list with read-only/editable TextFields (Value + Label) - Custom attr expanded values: same inline edit pattern as RSVP options (Value + Label TextFields, Edit/Save/Discard/Add/Remove) - displayAs: constrained to valid options per field type via getDisplayAsOptions(); column suppressed for non-select types; reset on incompatible type change - Badge fixes: informative → neutral for type/inputType labels; wrapped in flex div to prevent full-width stretch in table columns; locale code column uses plain Text - isRowExpandable predicate on DataTable/ResourceDashboardLayout: suppress expand chevron for RSVP fields with no options/rules/default/locale-overrides, and for custom attrs with no values - Custom attr dialog: size="L" - Fix Update button bug in custom attr dialog: normalize undefined label on openAttrEdit Co-Authored-By: Claude Sonnet 4.6 --- web-src/src/components/shared/DataTable.tsx | 25 +- .../shared/ResourceDashboardLayout.tsx | 3 + .../ConfigManagement/ConfigManagement.tsx | 1245 +++++++++++++---- web-src/src/types/configApi.ts | 15 +- 4 files changed, 999 insertions(+), 289 deletions(-) diff --git a/web-src/src/components/shared/DataTable.tsx b/web-src/src/components/shared/DataTable.tsx index 9ec17d7..3198228 100644 --- a/web-src/src/components/shared/DataTable.tsx +++ b/web-src/src/components/shared/DataTable.tsx @@ -48,6 +48,7 @@ interface DataTableProps { renderExpandedContent?: (item: T) => React.ReactNode expandedKeys?: Set onToggleExpand?: (key: string) => void + isRowExpandable?: (item: T) => boolean } const iconMap = { @@ -275,7 +276,8 @@ export function DataTable>({ onVisibleItemsChange, renderExpandedContent, expandedKeys, - onToggleExpand + onToggleExpand, + isRowExpandable, }: DataTableProps): React.ReactElement { const isExpandable = !!renderExpandedContent @@ -550,20 +552,23 @@ export function DataTable>({ {paginatedData.map((item) => { const itemKey = getItemKey(item) - const isExpanded = isExpandable && effectiveExpandedKeys.has(itemKey) + const rowExpandable = isExpandable && (!isRowExpandable || isRowExpandable(item)) + const isExpanded = rowExpandable && effectiveExpandedKeys.has(itemKey) return ( {isExpandable && ( - handleToggleExpand(itemKey)} - aria-label={isExpanded ? 'Collapse row' : 'Expand row'} - UNSAFE_style={{ padding: 0 }} - > - {isExpanded ? : } - + {rowExpandable && ( + handleToggleExpand(itemKey)} + aria-label={isExpanded ? 'Collapse row' : 'Expand row'} + UNSAFE_style={{ padding: 0 }} + > + {isExpanded ? : } + + )} )} {allColumns.map((column) => { diff --git a/web-src/src/components/shared/ResourceDashboardLayout.tsx b/web-src/src/components/shared/ResourceDashboardLayout.tsx index d05efb3..b64a0bf 100644 --- a/web-src/src/components/shared/ResourceDashboardLayout.tsx +++ b/web-src/src/components/shared/ResourceDashboardLayout.tsx @@ -57,6 +57,7 @@ interface ResourceDashboardLayoutProps { renderExpandedContent?: (item: T) => React.ReactNode expandedKeys?: Set onToggleExpand?: (key: string) => void + isRowExpandable?: (item: T) => boolean } export function ResourceDashboardLayout>({ @@ -85,6 +86,7 @@ export function ResourceDashboardLayout>({ renderExpandedContent, expandedKeys, onToggleExpand, + isRowExpandable, }: ResourceDashboardLayoutProps): React.ReactElement { const [inputValue, setInputValue] = useState('') const [debouncedQuery, setDebouncedQuery] = useState('') @@ -255,6 +257,7 @@ export function ResourceDashboardLayout>({ renderExpandedContent={renderExpandedContent} expandedKeys={expandedKeys} onToggleExpand={onToggleExpand} + isRowExpandable={isRowExpandable} emptyState={ debouncedQuery ? ( = () => { const [editingRsvpConfig, setEditingRsvpConfig] = useState(null) const [rsvpFormFields, setRsvpFormFields] = useState([]) const [rsvpLocalizations, setRsvpLocalizations] = useState>({}) - const [rsvpLocaleEditing, setRsvpLocaleEditing] = useState(null) const [rsvpConfigToDelete, setRsvpConfigToDelete] = useState(null) + // Collapsible field cards in RSVP dialog + const [expandedRsvpDialogFields, setExpandedRsvpDialogFields] = useState>(new Set([0])) + // Active locale for the RSVP dashboard locale switcher + const [activeLocale, setActiveLocale] = useState(null) + + // Per-field inline actions + const [editingFieldDialog, setEditingFieldDialog] = useState<{ field: RsvpFormField; index: number } | null>(null) + const [editingFieldForm, setEditingFieldForm] = useState(createEmptyRsvpField()) + const [fieldToDelete, setFieldToDelete] = useState<{ field: RsvpFormField; index: number } | null>(null) + // Per-option inline editing (keyed by field.field name) + const [pendingOptionEdits, setPendingOptionEdits] = useState>({}) + const [savingOptionKey, setSavingOptionKey] = useState(null) // Expandable state for RSVP fields table const [expandedFieldKeys, setExpandedFieldKeys] = useState>(new Set()) @@ -174,6 +195,9 @@ export const ConfigManagement: React.FC = () => { // Expandable state for attributes table const [expandedAttrKeys, setExpandedAttrKeys] = useState>(new Set()) + // Per-value inline editing for attributes table (keyed by attributeId) + const [pendingAttrValueEdits, setPendingAttrValueEdits] = useState>({}) + const [savingAttrValueKey, setSavingAttrValueKey] = useState(null) // Action state const [isSaving, setIsSaving] = useState(false) @@ -275,8 +299,16 @@ export const ConfigManagement: React.FC = () => { useEffect(() => { setExpandedFieldKeys(new Set()) setExpandedAttrKeys(new Set()) + setPendingOptionEdits({}) + setPendingAttrValueEdits({}) + setActiveLocale(null) }, [selectedScopeId]) + // Discard any pending option edits when the config reloads (save or refresh) + useEffect(() => { + setPendingOptionEdits({}) + }, [rsvpConfig]) + // Drop scope selection if it falls outside the picker pool useEffect(() => { if (!selectedScopeId) return @@ -293,7 +325,7 @@ export const ConfigManagement: React.FC = () => { setEditingRsvpConfig(null) setRsvpFormFields([createEmptyRsvpField()]) setRsvpLocalizations({}) - setRsvpLocaleEditing(null) + setExpandedRsvpDialogFields(new Set([0])) setIsRsvpFormOpen(true) }, []) @@ -301,7 +333,7 @@ export const ConfigManagement: React.FC = () => { setEditingRsvpConfig(config) setRsvpFormFields([...config.rsvpFormFields]) setRsvpLocalizations(config.localizations ? JSON.parse(JSON.stringify(config.localizations)) : {}) - setRsvpLocaleEditing(null) + setExpandedRsvpDialogFields(new Set([0])) setIsRsvpFormOpen(true) }, []) @@ -354,6 +386,212 @@ export const ConfigManagement: React.FC = () => { } }, [selectedScopeId, rsvpFormFields, rsvpLocalizations, editingRsvpConfig, apiService, toast, loadConfigs]) + const openFieldEdit = useCallback((item: RsvpFormField & { _key: string }) => { + const index = rsvpConfig?.rsvpFormFields.findIndex(f => f.field === item.field) ?? -1 + if (index === -1) return + setEditingFieldDialog({ field: item, index }) + if (activeLocale) { + const override = rsvpConfig?.localizations?.[activeLocale]?.rsvpFormFields?.find(f => f.field === item.field) + // Non-translatable fields from base; translatable fields from locale override (blank if no override) + setEditingFieldForm({ + ...item, + label: override?.label ?? '', + placeholder: override?.placeholder ?? '', + options: item.options.map(o => ({ + value: o.value, + label: override?.options?.find(oo => oo.value === o.value)?.label ?? '', + })), + }) + } else { + setEditingFieldForm({ ...item }) + } + }, [rsvpConfig, activeLocale]) + + // Reference to the original base field used by handleSaveFieldEdit in locale mode + // (captured at the time the dialog was opened, not reactive) + const editingFieldBaseRef = React.useRef(null) + React.useEffect(() => { + if (editingFieldDialog) { + editingFieldBaseRef.current = rsvpConfig?.rsvpFormFields[editingFieldDialog.index] ?? null + } else { + editingFieldBaseRef.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editingFieldDialog]) + + const handleSaveFieldEdit = useCallback(async () => { + if (!selectedScopeId || !rsvpConfig || editingFieldDialog == null) return + setIsSaving(true) + try { + let updatedFields = [...rsvpConfig.rsvpFormFields] + let updatedLocalizations = rsvpConfig.localizations + ? JSON.parse(JSON.stringify(rsvpConfig.localizations)) as Record + : {} + + if (activeLocale) { + // Save non-translatable fields to the base field + const baseField = editingFieldBaseRef.current ?? rsvpConfig.rsvpFormFields[editingFieldDialog.index] + updatedFields[editingFieldDialog.index] = { + ...baseField, + field: editingFieldForm.field, + type: editingFieldForm.type, + required: editingFieldForm.required, + displayAs: editingFieldForm.displayAs, + rules: editingFieldForm.rules, + default: editingFieldForm.default, + // base-level label/placeholder/options stay from baseField (not locale values) + } + // Save translatable fields to locale override + const override: RsvpFormFieldLocaleOverride = { + field: editingFieldForm.field, + label: editingFieldForm.label || undefined, + placeholder: editingFieldForm.placeholder || undefined, + options: editingFieldForm.options.some(o => o.label.trim()) + ? editingFieldForm.options.filter(o => o.label.trim()) + : undefined, + } + if (!updatedLocalizations[activeLocale]) updatedLocalizations[activeLocale] = { rsvpFormFields: [] } + const localeFields = updatedLocalizations[activeLocale].rsvpFormFields + const existingIdx = localeFields.findIndex(f => f.field === override.field) + if (existingIdx >= 0) localeFields[existingIdx] = override + else localeFields.push(override) + updatedLocalizations[activeLocale].rsvpFormFields = localeFields.filter( + f => f.label || f.placeholder || (f.options && f.options.length > 0) + ) + if (updatedLocalizations[activeLocale].rsvpFormFields.length === 0) delete updatedLocalizations[activeLocale] + } else { + updatedFields[editingFieldDialog.index] = editingFieldForm + } + + const result = await apiService.updateConfig(selectedScopeId, rsvpConfig.configId, { + ...rsvpConfig, + rsvpFormFields: updatedFields, + localizations: updatedLocalizations, + }) + if ('error' in result) { + const status = (result as { status: number }).status + toast.error(status === 409 ? 'Config modified by someone else. Refresh and try again.' : 'Failed to update field') + return + } + toast.success('Field updated') + setEditingFieldDialog(null) + await loadConfigs() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update field') + } finally { + setIsSaving(false) + } + }, [selectedScopeId, rsvpConfig, editingFieldDialog, editingFieldForm, activeLocale, apiService, toast, loadConfigs]) + + const handleDeleteField = useCallback(async () => { + if (!selectedScopeId || !rsvpConfig || fieldToDelete == null) return + const updatedFields = rsvpConfig.rsvpFormFields.filter((_, i) => i !== fieldToDelete.index) + setIsSaving(true) + try { + const result = await apiService.updateConfig(selectedScopeId, rsvpConfig.configId, { + ...rsvpConfig, + rsvpFormFields: updatedFields, + }) + if ('error' in result) { + toast.error('Failed to delete field') + return + } + toast.success('Field deleted') + setFieldToDelete(null) + await loadConfigs() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete field') + } finally { + setIsSaving(false) + } + }, [selectedScopeId, rsvpConfig, fieldToDelete, apiService, toast, loadConfigs]) + + const handleSaveFieldOptions = useCallback(async (fieldName: string) => { + if (!selectedScopeId || !rsvpConfig) return + const newOptions = (pendingOptionEdits[fieldName] ?? []).filter(o => o.value.trim()) + setSavingOptionKey(fieldName) + try { + let result + if (activeLocale) { + const updatedLocalizations: Record = + rsvpConfig.localizations ? JSON.parse(JSON.stringify(rsvpConfig.localizations)) : {} + if (!updatedLocalizations[activeLocale]) updatedLocalizations[activeLocale] = { rsvpFormFields: [] } + const fields = updatedLocalizations[activeLocale].rsvpFormFields + const existing = fields.find(f => f.field === fieldName) + if (existing) existing.options = newOptions.length ? newOptions : undefined + else fields.push({ field: fieldName, options: newOptions.length ? newOptions : undefined }) + updatedLocalizations[activeLocale].rsvpFormFields = fields.filter( + f => f.label || f.placeholder || (f.options && f.options.length > 0) + ) + if (updatedLocalizations[activeLocale].rsvpFormFields.length === 0) delete updatedLocalizations[activeLocale] + result = await apiService.updateConfig(selectedScopeId, rsvpConfig.configId, { + ...rsvpConfig, + localizations: updatedLocalizations, + }) + } else { + const updatedFields = rsvpConfig.rsvpFormFields.map(f => + f.field === fieldName ? { ...f, options: newOptions } : f + ) + result = await apiService.updateConfig(selectedScopeId, rsvpConfig.configId, { + ...rsvpConfig, + rsvpFormFields: updatedFields, + }) + } + if ('error' in result) { + toast.error('Failed to save options') + return + } + toast.success('Options saved') + setPendingOptionEdits(prev => { + const next = { ...prev } + delete next[fieldName] + return next + }) + await loadConfigs() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save options') + } finally { + setSavingOptionKey(null) + } + }, [selectedScopeId, rsvpConfig, pendingOptionEdits, activeLocale, apiService, toast, loadConfigs]) + + const handleSaveAttrValues = useCallback(async (attributeId: string) => { + if (!selectedScopeId || !customAttrsConfig) return + const newValues = (pendingAttrValueEdits[attributeId] ?? []) + .filter(v => v.value.trim()) + .map((v, i) => ({ + valueId: v.valueId || generateUUID(), + value: v.value.trim(), + label: (v.label ?? '').trim() || v.value.trim(), + displayOrder: i, + })) + setSavingAttrValueKey(attributeId) + try { + const updatedAttributes = customAttrsConfig.attributes.map(a => + a.attributeId === attributeId ? { ...a, values: newValues } : a + ) + const result = await apiService.updateConfig(selectedScopeId, customAttrsConfig.configId, { + ...customAttrsConfig, + attributes: updatedAttributes, + }) + if ('error' in result) { + toast.error('Failed to save values') + return + } + toast.success('Values saved') + setPendingAttrValueEdits(prev => { + const next = { ...prev } + delete next[attributeId] + return next + }) + await loadConfigs() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save values') + } finally { + setSavingAttrValueKey(null) + } + }, [selectedScopeId, customAttrsConfig, pendingAttrValueEdits, apiService, toast, loadConfigs]) + const handleDeleteConfig = useCallback(async (config: ScopeConfig) => { if (!selectedScopeId) return setIsSaving(true) @@ -469,7 +707,7 @@ export const ConfigManagement: React.FC = () => { setEditingAttr(attr) setAttrFormName(attr.name) setAttrFormInputType(attr.inputType) - setAttrFormValues([...attr.values]) + setAttrFormValues(attr.values.map(v => ({ ...v, label: v.label ?? '' }))) setAttrFormEnabled(attr.enabled) setIsAttrFormOpen(true) }, []) @@ -490,6 +728,7 @@ export const ConfigManagement: React.FC = () => { .map((v, i) => ({ valueId: v.valueId || generateUUID(), value: v.value.trim(), + label: (v.label ?? '').trim() || v.value.trim(), displayOrder: i, })), } @@ -591,53 +830,6 @@ export const ConfigManagement: React.FC = () => { }) }, []) - // ============================================================================ - // RSVP LOCALIZATION HELPERS - // ============================================================================ - - const getLocaleOverrides = useCallback((locale: string): RsvpFormFieldLocaleOverride[] => { - return rsvpLocalizations[locale]?.rsvpFormFields || [] - }, [rsvpLocalizations]) - - const setLocaleOverrideField = useCallback((locale: string, fieldName: string, prop: 'label' | 'placeholder', value: string) => { - setRsvpLocalizations(prev => { - const copy = JSON.parse(JSON.stringify(prev)) - if (!copy[locale]) copy[locale] = { rsvpFormFields: [] } - const fields = copy[locale].rsvpFormFields as RsvpFormFieldLocaleOverride[] - const existing = fields.find(f => f.field === fieldName) - if (existing) { - existing[prop] = value || undefined - } else { - fields.push({ field: fieldName, [prop]: value || undefined }) - } - // Remove entries with no overrides - copy[locale].rsvpFormFields = fields.filter( - (f: RsvpFormFieldLocaleOverride) => f.label || f.placeholder || (f.options && f.options.length > 0) - ) - if (copy[locale].rsvpFormFields.length === 0) delete copy[locale] - return copy - }) - }, []) - - const setLocaleOverrideOptions = useCallback((locale: string, fieldName: string, optionsStr: string) => { - setRsvpLocalizations(prev => { - const copy = JSON.parse(JSON.stringify(prev)) - if (!copy[locale]) copy[locale] = { rsvpFormFields: [] } - const fields = copy[locale].rsvpFormFields as RsvpFormFieldLocaleOverride[] - const existing = fields.find(f => f.field === fieldName) - const options = optionsStr ? optionsStr.split('\n').map(o => o.trim()).filter(Boolean) : undefined - if (existing) { - existing.options = options - } else { - fields.push({ field: fieldName, options }) - } - copy[locale].rsvpFormFields = fields.filter( - (f: RsvpFormFieldLocaleOverride) => f.label || f.placeholder || (f.options && f.options.length > 0) - ) - if (copy[locale].rsvpFormFields.length === 0) delete copy[locale] - return copy - }) - }, []) // ============================================================================ // RSVP TABLE COLUMNS (for display) @@ -651,16 +843,53 @@ export const ConfigManagement: React.FC = () => { })) }, [rsvpConfig]) + const isOwnRsvpConfig = rsvpConfig?.scopeId === selectedScopeId + + const rsvpFieldActions = useMemo(() => { + if (!canWriteConfig || !isOwnRsvpConfig) return [] + return [ + { + icon: 'edit' as const, + label: 'Edit field', + onAction: (item: RsvpFormField & { _key: string }) => openFieldEdit(item), + }, + { + icon: 'delete' as const, + label: 'Delete field', + onAction: (item: RsvpFormField & { _key: string }) => { + const index = rsvpConfig?.rsvpFormFields.findIndex(f => f.field === item.field) ?? -1 + if (index !== -1) setFieldToDelete({ field: item, index }) + }, + }, + ] + }, [canWriteConfig, isOwnRsvpConfig, openFieldEdit, rsvpConfig]) + const rsvpFieldColumns = useMemo(() => [ { key: 'field', name: 'FIELD NAME', width: 160, sortable: true }, - { key: 'label', name: 'LABEL', width: 160, sortable: true }, + { + key: 'label', + name: 'LABEL', + width: 160, + sortable: true, + render: (item: RsvpFormField & { _key: string }) => { + const override = activeLocale + ? rsvpConfig?.localizations?.[activeLocale]?.rsvpFormFields?.find(f => f.field === item.field) + : null + const localeLabel = override?.label + return localeLabel + ? {localeLabel} + : {item.label} + }, + }, { key: 'type', name: 'TYPE', width: 120, sortable: true, render: (item: RsvpFormField & { _key: string }) => ( - {item.type} +
+ {item.type} +
), }, { @@ -687,23 +916,130 @@ export const ConfigManagement: React.FC = () => { width: 120, sortable: false, render: (item: RsvpFormField & { _key: string }) => ( - {item.displayAs || '-'} + + {(item.type === 'select' || item.type === 'multi-select') && item.displayAs + ? item.displayAs + : '-'} + ), }, - ], []) + ], [activeLocale, rsvpConfig]) const renderRsvpExpandedContent = useCallback((item: RsvpFormField & { _key: string }) => { const locales = Object.keys(rsvpConfig?.localizations || {}) + const isSelectType = item.type === 'select' || item.type === 'multi-select' + const pendingOpts = pendingOptionEdits[item.field] + const isEditing = pendingOpts !== undefined + const displayOpts = isEditing ? pendingOpts : item.options + const canEdit = canWriteConfig && isOwnRsvpConfig + return (
- {item.options.length > 0 && ( + {isSelectType && (
- Options: -
- {item.options.map((opt, i) => ( - {opt} - ))} + {/* Options section header */} +
+ + Options ({displayOpts.length}): + +
+ {isEditing ? ( + <> + setPendingOptionEdits(prev => { + const next = { ...prev } + delete next[item.field] + return next + })} + > + + Discard + + + + ) : canEdit && ( + setPendingOptionEdits(prev => ({ ...prev, [item.field]: [...item.options] }))} + > + + Edit Options + + )} +
+ {/* Options list */} + {displayOpts.length > 0 ? ( +
+ {displayOpts.map((opt, i) => ( +
+ setPendingOptionEdits(prev => { + const opts = [...(prev[item.field] ?? [])] + opts[i] = { ...opts[i], value: v } + return { ...prev, [item.field]: opts } + })} + styles={style({ flexGrow: 1 })} + /> + setPendingOptionEdits(prev => { + const opts = [...(prev[item.field] ?? [])] + opts[i] = { ...opts[i], label: v } + return { ...prev, [item.field]: opts } + })} + styles={style({ flexGrow: 1 })} + /> + {isEditing && ( + setPendingOptionEdits(prev => ({ + ...prev, + [item.field]: (prev[item.field] ?? []).filter((_, oi) => oi !== i), + }))} + > + + + )} +
+ ))} + {isEditing && ( +
+ setPendingOptionEdits(prev => ({ + ...prev, + [item.field]: [...(prev[item.field] ?? []), { value: '', label: '' }], + }))} + > + + Add Option + +
+ )} +
+ ) : ( + + {isEditing ? 'No options — add one above.' : 'No options defined.'} + + )}
)} {item.rules && ( @@ -728,23 +1064,34 @@ export const ConfigManagement: React.FC = () => { if (!override) return null return (
- {locale} +
{locale}
{override.label && Label: {override.label}} {override.placeholder && Placeholder: {override.placeholder}} - {override.options && Options: {override.options.join(', ')}} + {override.options && Options: {override.options.map(o => o.label || o.value).join(', ')}}
) })}
)} - {!item.options.length && !item.rules && !item.default && locales.length === 0 && ( + {!isSelectType && !item.rules && !item.default && locales.length === 0 && ( No additional details )}
) + }, [rsvpConfig, pendingOptionEdits, savingOptionKey, canWriteConfig, isOwnRsvpConfig, setPendingOptionEdits, handleSaveFieldOptions]) + + const isRsvpFieldExpandable = useCallback((item: RsvpFormField & { _key: string }) => { + const isSelectType = item.type === 'select' || item.type === 'multi-select' + if (isSelectType) return true + if (item.rules) return true + if (item.default) return true + const hasLocaleOverride = Object.values(rsvpConfig?.localizations || {}).some( + loc => loc.rsvpFormFields.some(f => f.field === item.field) + ) + return hasLocaleOverride }, [rsvpConfig]) // ============================================================================ @@ -761,9 +1108,11 @@ export const ConfigManagement: React.FC = () => { width: 100, sortable: true, render: (item: CustomAttributeConfig) => ( - - {item.enabled ? 'Yes' : 'No'} - +
+ + {item.enabled ? 'Yes' : 'No'} + +
), }, { @@ -772,7 +1121,9 @@ export const ConfigManagement: React.FC = () => { width: 140, sortable: true, render: (item: CustomAttributeConfig) => ( - {item.inputType} +
+ {item.inputType} +
), }, { @@ -826,23 +1177,118 @@ export const ConfigManagement: React.FC = () => { ], [scopes, customAttrsConfig, isOwnAttrsConfig, canWriteConfig, canDeleteConfig, openAttrEdit]) const renderAttrExpandedContent = useCallback((item: CustomAttributeConfig) => { - if (item.values.length === 0) { - return ( - - No values defined (free-form input) - - ) - } + const pendingVals = pendingAttrValueEdits[item.attributeId] + const isEditing = pendingVals !== undefined + const displayVals = isEditing ? pendingVals : [...item.values].sort((a, b) => a.displayOrder - b.displayOrder) + const canEdit = canWriteConfig && isOwnAttrsConfig + return ( -
- {item.values - .sort((a, b) => a.displayOrder - b.displayOrder) - .map((v, i) => ( - {v.value} - ))} +
+
+ {/* Values section header */} +
+ + Values ({displayVals.length}): + +
+ {isEditing ? ( + <> + setPendingAttrValueEdits(prev => { + const next = { ...prev } + delete next[item.attributeId] + return next + })} + > + + Discard + + + + ) : canEdit && ( + setPendingAttrValueEdits(prev => ({ + ...prev, + [item.attributeId]: [...item.values].sort((a, b) => a.displayOrder - b.displayOrder).map(v => ({ ...v, label: v.label ?? '' })), + }))} + > + + Edit Values + + )} +
+
+ {/* Values list */} +
+ {displayVals.map((v, i) => ( +
+ setPendingAttrValueEdits(prev => { + const vals = [...(prev[item.attributeId] ?? [])] + vals[i] = { ...vals[i], value: val } + return { ...prev, [item.attributeId]: vals } + })} + styles={style({ flexGrow: 1 })} + /> + setPendingAttrValueEdits(prev => { + const vals = [...(prev[item.attributeId] ?? [])] + vals[i] = { ...vals[i], label: val } + return { ...prev, [item.attributeId]: vals } + })} + styles={style({ flexGrow: 1 })} + /> + {isEditing && ( + setPendingAttrValueEdits(prev => ({ + ...prev, + [item.attributeId]: (prev[item.attributeId] ?? []).filter((_, vi) => vi !== i), + }))} + > + + + )} +
+ ))} + {isEditing && ( +
+ setPendingAttrValueEdits(prev => ({ + ...prev, + [item.attributeId]: [...(prev[item.attributeId] ?? []), { value: '', label: '', displayOrder: (prev[item.attributeId] ?? []).length }], + }))} + > + + Add Value + +
+ )} +
+
) - }, []) + }, [pendingAttrValueEdits, savingAttrValueKey, canWriteConfig, isOwnAttrsConfig, setPendingAttrValueEdits, handleSaveAttrValues]) // ============================================================================ // LOADING OVERLAY @@ -943,6 +1389,7 @@ export const ConfigManagement: React.FC = () => { error={null} data={rsvpFieldsForTable} columns={rsvpFieldColumns} + actions={rsvpFieldActions} getItemKey={(item) => item._key} createButton={canWriteConfig ? ( @@ -966,6 +1413,24 @@ export const ConfigManagement: React.FC = () => { renderExpandedContent={renderRsvpExpandedContent} expandedKeys={expandedFieldKeys} onToggleExpand={handleToggleFieldExpand} + isRowExpandable={isRsvpFieldExpandable} + toolbarEnd={ + setActiveLocale(key === '' ? null : key as string)} + styles={style({ width: 220 })} + > + + Base (default) + + {availableLocales.map(l => ( + + {l.name} ({l.code}) + + ))} + + } /> ) : (
= () => { {Object.entries(localesConfig.localeNames).map(([code, name]) => ( - {code} + {code} {name} @@ -1097,6 +1562,7 @@ export const ConfigManagement: React.FC = () => { renderExpandedContent={renderAttrExpandedContent} expandedKeys={expandedAttrKeys} onToggleExpand={handleToggleAttrExpand} + isRowExpandable={(item: CustomAttributeConfig) => item.values.length > 0} />
@@ -1121,6 +1587,166 @@ export const ConfigManagement: React.FC = () => { DIALOGS ══════════════════════════════════════════════════════════════════════ */} + {/* Per-Field Edit Dialog */} + { if (!open) setEditingFieldDialog(null) }} + > +
+ + {({ close }) => ( + <> + Edit Field + +
+ {activeLocale && ( +
+ + Locale: {activeLocale} — Label, Placeholder, and Option Labels save as locale overrides. All other fields update the base definition. + +
+ )} +
+ setEditingFieldForm(prev => ({ ...prev, field: v }))} + isRequired + /> + setEditingFieldForm(prev => ({ ...prev, label: v }))} + isRequired={!activeLocale} + /> + setEditingFieldForm(prev => ({ ...prev, placeholder: v }))} + /> + setEditingFieldForm(prev => { + const newType = key as RsvpFieldType + const needsReset = + (newType === 'multi-select' && prev.displayAs === 'radio') || + (newType === 'select' && prev.displayAs === 'checkbox') + return { + ...prev, + type: newType, + displayAs: needsReset ? '' : prev.displayAs, + options: (newType === 'text' || newType === 'email' || newType === 'phone') ? [] : prev.options, + } + })} + > + {RSVP_FIELD_TYPES.map(t => ( + {t.label} + ))} + + {(editingFieldForm.type === 'select' || editingFieldForm.type === 'multi-select') && ( + setEditingFieldForm(prev => ({ ...prev, displayAs: key as RsvpDisplayAs }))} + > + {getDisplayAsOptions(editingFieldForm.type).map(o => ( + {o.label} + ))} + + )} +
+ setEditingFieldForm(prev => ({ ...prev, required: v }))} + > + Required + + {(editingFieldForm.type === 'select' || editingFieldForm.type === 'multi-select') && ( +
+
+ + Options{editingFieldForm.options.length > 0 ? ` (${editingFieldForm.options.length})` : ''} + +
+
0 + ? style({ display: 'flex', flexDirection: 'column', gap: 8, paddingX: 12, paddingY: 8, backgroundColor: 'gray-75', borderWidth: 1, borderColor: 'gray-300', borderRadius: 'sm' }) + : style({ display: 'flex', flexDirection: 'column', gap: 8 }) + }> + {editingFieldForm.options.map((opt, optIdx) => ( +
+ setEditingFieldForm(prev => { + const opts = [...prev.options] + opts[optIdx] = { ...opts[optIdx], value: v } + return { ...prev, options: opts } + })} + styles={style({ flexGrow: 1 })} + /> + setEditingFieldForm(prev => { + const opts = [...prev.options] + opts[optIdx] = { ...opts[optIdx], label: v } + return { ...prev, options: opts } + })} + styles={style({ flexGrow: 1 })} + /> + {!activeLocale && ( + setEditingFieldForm(prev => ({ + ...prev, + options: prev.options.filter((_, oi) => oi !== optIdx), + }))} + > + + + )} +
+ ))} + {editingFieldForm.options.length === 0 && ( + + No options defined. + + )} + {!activeLocale && ( +
+ setEditingFieldForm(prev => ({ ...prev, options: [...prev.options, { value: '', label: '' }] }))} + > + + Add Option + +
+ )} +
+
+ )} +
+
+ + + + + + )} +
+ + {/* RSVP Config Create/Edit Dialog */}
@@ -1137,196 +1763,227 @@ export const ConfigManagement: React.FC = () => {
-
- {rsvpFormFields.map((field, index) => ( -
-
- Field {index + 1} - setRsvpFormFields(prev => prev.filter((_, i) => i !== index))} - isDisabled={rsvpFormFields.length <= 1} - > - - -
-
- setRsvpFormFields(prev => { - const copy = [...prev] - copy[index] = { ...copy[index], field: v } - return copy - })} - isRequired - /> - setRsvpFormFields(prev => { - const copy = [...prev] - copy[index] = { ...copy[index], label: v } - return copy - })} - isRequired - /> - setRsvpFormFields(prev => { - const copy = [...prev] - copy[index] = { ...copy[index], placeholder: v } - return copy - })} - /> - setRsvpFormFields(prev => { - const copy = [...prev] - copy[index] = { ...copy[index], type: key as RsvpFieldType } - return copy - })} +
+ {rsvpFormFields.map((field, index) => { + const hasOptions = field.type === 'select' || field.type === 'multi-select' + const isCollapsible = hasOptions + const isExpanded = !isCollapsible || expandedRsvpDialogFields.has(index) + const toggleExpand = isCollapsible + ? () => setExpandedRsvpDialogFields(prev => { + const next = new Set(prev) + next.has(index) ? next.delete(index) : next.add(index) + return next + }) + : undefined + return ( +
+ {/* Header (collapsible for select/multi-select, static otherwise) */} +
- {RSVP_FIELD_TYPES.map(t => ( - {t.label} - ))} - - {(field.type === 'select' || field.type === 'multi-select') && ( - setRsvpFormFields(prev => { - const copy = [...prev] - copy[index] = { ...copy[index], displayAs: key as RsvpDisplayAs } - return copy - })} +
+ {isCollapsible && ( + + )} + {field.field || `Field ${index + 1}`} +
+ {field.type} +
+ {field.required && ( +
+ Required +
+ )} +
+ { e.continuePropagation?.(); setRsvpFormFields(prev => prev.filter((_, i) => i !== index)) }} + isDisabled={rsvpFormFields.length <= 1} > - {RSVP_DISPLAY_AS_OPTIONS.map(o => ( - {o.label} - ))} -
- )} -
-
- setRsvpFormFields(prev => { - const copy = [...prev] - copy[index] = { ...copy[index], required: v } - return copy - })} - > - Required - -
- {(field.type === 'select' || field.type === 'multi-select') && ( -
- setRsvpFormFields(prev => { - const copy = [...prev] - copy[index] = { - ...copy[index], - options: v.split('\n').map(o => o.trim()).filter(Boolean), - } - return copy - })} - styles={style({ width: '[100%]' })} - /> + +
- )} -
- ))} -
-
- - {/* Localizations */} -
- Localizations -
- setRsvpLocaleEditing(key as string | null)} - styles={style({ width: 280 })} - > - {availableLocales.map(l => ( - - {l.name} ({l.code}) - - ))} - -
- {rsvpLocaleEditing && ( -
- - Localizations for {rsvpLocaleEditing} - -
- {rsvpFormFields.filter(f => f.field.trim()).map((field, i) => { - const overrides = getLocaleOverrides(rsvpLocaleEditing!) - const override = overrides.find(o => o.field === field.field) - return ( -
- - {field.field}: - - setLocaleOverrideField(rsvpLocaleEditing!, field.field, 'label', v)} - styles={style({ width: 200 })} - /> - setLocaleOverrideField(rsvpLocaleEditing!, field.field, 'placeholder', v)} - styles={style({ width: 200 })} - /> - {(field.type === 'select' || field.type === 'multi-select') && ( + {/* Body — always shown for non-select types; toggled for select/multi-select */} + {isExpanded && ( +
+
+ setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], field: v } + return copy + })} + isRequired + /> + setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], label: v } + return copy + })} + isRequired + /> setLocaleOverrideOptions(rsvpLocaleEditing!, field.field, v)} - styles={style({ width: 200 })} + label="Placeholder" + value={field.placeholder} + onChange={(v) => setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], placeholder: v } + return copy + })} /> + setRsvpFormFields(prev => { + const copy = [...prev] + const newType = key as RsvpFieldType + const needsReset = + (newType === 'multi-select' && copy[index].displayAs === 'radio') || + (newType === 'select' && copy[index].displayAs === 'checkbox') + copy[index] = { + ...copy[index], + type: newType, + displayAs: needsReset ? '' : copy[index].displayAs, + options: (newType === 'text' || newType === 'email' || newType === 'phone') ? [] : copy[index].options, + } + return copy + })} + > + {RSVP_FIELD_TYPES.map(t => ( + {t.label} + ))} + + {(field.type === 'select' || field.type === 'multi-select') && ( + setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], displayAs: key as RsvpDisplayAs } + return copy + })} + > + {getDisplayAsOptions(field.type).map(o => ( + {o.label} + ))} + + )} +
+
+ setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], required: v } + return copy + })} + > + Required + +
+ {(field.type === 'select' || field.type === 'multi-select') && ( +
+
+ + Options{field.options.length > 0 ? ` (${field.options.length})` : ''} + + setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], options: [...copy[index].options, { value: '', label: '' }] } + return copy + })} + > + + Add Option + +
+
0 + ? style({ display: 'flex', flexDirection: 'column', gap: 8, paddingX: 12, paddingY: 8, backgroundColor: 'layer-2', borderWidth: 1, borderColor: 'gray-300', borderRadius: 'sm' }) + : style({ display: 'flex', flexDirection: 'column', gap: 8 }) + } + > + {field.options.map((opt, optIdx) => ( +
+ setRsvpFormFields(prev => { + const copy = [...prev] + const opts = [...copy[index].options] + opts[optIdx] = { ...opts[optIdx], value: v } + copy[index] = { ...copy[index], options: opts } + return copy + })} + styles={style({ flexGrow: 1 })} + /> + setRsvpFormFields(prev => { + const copy = [...prev] + const opts = [...copy[index].options] + opts[optIdx] = { ...opts[optIdx], label: v } + copy[index] = { ...copy[index], options: opts } + return copy + })} + styles={style({ flexGrow: 1 })} + /> + setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { + ...copy[index], + options: copy[index].options.filter((_, oi) => oi !== optIdx), + } + return copy + })} + > + + +
+ ))} + {field.options.length === 0 && ( + + No options yet — click "Add Option" above. + + )} +
+
)}
- ) - })} -
-
- )} + )} +
+ ) + })} +
+
@@ -1440,7 +2097,7 @@ export const ConfigManagement: React.FC = () => { {/* Custom Attribute Create/Edit Dialog */}
- + {({close}) => ( <> {editingAttr ? 'Edit Custom Attribute' : 'Create Custom Attribute'} @@ -1477,31 +2134,50 @@ export const ConfigManagement: React.FC = () => { {(attrFormInputType === 'single-select' || attrFormInputType === 'multi-select') && (
- Values - +
-
+
0 + ? style({ display: 'flex', flexDirection: 'column', gap: 8, paddingX: 12, paddingY: 8, backgroundColor: 'gray-75', borderWidth: 1, borderColor: 'gray-300', borderRadius: 'sm' }) + : style({ display: 'flex', flexDirection: 'column', gap: 8 }) + }> {attrFormValues.map((val, index) => ( -
+
+ + {index + 1}. + setAttrFormValues(prev => { const copy = [...prev] copy[index] = { ...copy[index], value: v } return copy })} - styles={style({ flexGrow: 1, width: '[100%]' })} + styles={style({ flexGrow: 1 })} + /> + setAttrFormValues(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], label: v } + return copy + })} + styles={style({ flexGrow: 1 })} /> = () => {
))} {attrFormValues.length === 0 && ( - - No values yet. Add values for users to select from. + + No values yet — click "Add Value" above. )}
@@ -1539,6 +2215,23 @@ export const ConfigManagement: React.FC = () => {
+ {/* Per-Field Delete Confirmation */} + !open && setFieldToDelete(null)} + > +
+ + Delete field {fieldToDelete?.field.field}? This will remove it from all events using this RSVP config and cannot be undone. + + + {/* Delete Confirmations */} Date: Wed, 8 Apr 2026 23:30:18 -0700 Subject: [PATCH 05/23] feat(event-form): add Custom Attributes card to Additional Content step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders scope-configured custom attributes (text, boolean, single-select, multi-select) in a new form card at the bottom of the Additional Content step. Multi-select uses an ordered repeater pattern to support user-defined downstream sequencing. Card always visible — shows progress indicator while loading and an empty-state message when no attributes are configured. Co-Authored-By: Claude Sonnet 4.6 --- .../EventForm/CustomAttributesComponent.tsx | 336 ++++++++++++++++++ web-src/src/pages/EventForm/EventForm.tsx | 23 +- web-src/src/pages/EventForm/index.ts | 1 + web-src/src/types/domain.ts | 9 + web-src/src/utils/dataFilters.ts | 1 + web-src/src/utils/eventFormMappers.ts | 1 + 6 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 web-src/src/pages/EventForm/CustomAttributesComponent.tsx diff --git a/web-src/src/pages/EventForm/CustomAttributesComponent.tsx b/web-src/src/pages/EventForm/CustomAttributesComponent.tsx new file mode 100644 index 0000000..fe87845 --- /dev/null +++ b/web-src/src/pages/EventForm/CustomAttributesComponent.tsx @@ -0,0 +1,336 @@ +/* +* +*/ + +import React, { useState, useEffect, useRef, useCallback } from 'react' +import { + Text, + Heading, + Divider, + TextField, + Switch, + Picker, + PickerItem, + Button, + ActionButton, + ProgressCircle, +} from '@react-spectrum/s2' +import { style } from '@react-spectrum/s2/style' with { type: 'macro' } +import Add from '@react-spectrum/s2/icons/Add' +import RemoveCircle from '@react-spectrum/s2/icons/RemoveCircle' +import { HeadingWithTooltip, FormCard } from '../../components/shared' +import { useEventFormComponent } from '../../hooks/useEventFormComponent' +import { useGroup } from '../../contexts/GroupContext' +import { cachedApi } from '../../services/api' +import type { CustomAttributeConfig, CustomAttributesScopeConfig, CustomAttributeValue } from '../../types/configApi' +import type { EventCustomAttributeValue } from '../../types/domain' + +// ============================================================================ +// MultiSelectRepeater sub-component +// ============================================================================ + +interface RepeaterRow { + id: string + value: string +} + +interface MultiSelectRepeaterProps { + attr: CustomAttributeConfig + values: EventCustomAttributeValue[] + onChange: (selectedValues: string[]) => void +} + +const MultiSelectRepeater: React.FC = ({ attr, values, onChange }) => { + const sortedOptions = attr.values.slice().sort((a, b) => a.displayOrder - b.displayOrder) + + const rowsFromValues = (vals: EventCustomAttributeValue[]): RepeaterRow[] => + vals.map((v, i) => ({ id: `row-${attr.attributeId}-${i}-${v.value}`, value: v.value })) + + const [rows, setRows] = useState(() => rowsFromValues(values)) + + // Sync from external changes (e.g., form load) without re-triggering on local updates + const prevValuesRef = useRef(JSON.stringify(values.map(v => v.value))) + const localUpdateRef = useRef(false) + + useEffect(() => { + if (localUpdateRef.current) { + localUpdateRef.current = false + prevValuesRef.current = JSON.stringify(values.map(v => v.value)) + return + } + const serialized = JSON.stringify(values.map(v => v.value)) + if (serialized === prevValuesRef.current) return + setRows(rowsFromValues(values)) + prevValuesRef.current = serialized + }, [values]) // eslint-disable-line react-hooks/exhaustive-deps + + const getAvailableOptions = (currentRowValue: string): CustomAttributeValue[] => { + const otherSelected = rows.map(r => r.value).filter(v => v && v !== currentRowValue) + return sortedOptions.filter(opt => !otherSelected.includes(opt.value)) + } + + const allOptionsUsed = rows.filter(r => r.value).length >= sortedOptions.length + + const addRow = useCallback(() => { + setRows(prev => [...prev, { id: `row-${attr.attributeId}-${Date.now()}`, value: '' }]) + }, [attr.attributeId]) + + const removeRow = useCallback((id: string) => { + setRows(prev => { + const next = prev.filter(r => r.id !== id) + localUpdateRef.current = true + onChange(next.map(r => r.value).filter(Boolean)) + return next + }) + }, [onChange]) + + const handleChange = useCallback((id: string, value: string) => { + setRows(prev => { + const next = prev.map(r => r.id === id ? { ...r, value } : r) + localUpdateRef.current = true + onChange(next.map(r => r.value).filter(Boolean)) + return next + }) + }, [onChange]) + + return ( +
+ {rows.map(row => ( +
+ handleChange(row.id, String(key))} + > + {getAvailableOptions(row.value).map(opt => ( + {opt.label || opt.value} + ))} + + removeRow(row.id)} + > + + +
+ ))} + + {!allOptionsUsed && ( + + )} +
+ ) +} + +// ============================================================================ +// CustomAttributesComponent +// ============================================================================ + +export const CustomAttributesComponent: React.FC = () => { + const { formData, updateFormData } = useEventFormComponent({ + componentId: 'custom-attributes', + }) + + const { activeGroup } = useGroup() + const [attributes, setAttributes] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const scopeId = activeGroup?.scopeId + if (!scopeId) { + setLoading(false) + return + } + + const load = async () => { + setLoading(true) + try { + const result = await cachedApi.getConfigsForScope(scopeId, 'custom-attributes') + if (!('error' in result)) { + const config = result.find(c => c.type === 'custom-attributes') as CustomAttributesScopeConfig | undefined + const enabled = (config?.attributes ?? []).filter(a => a.enabled !== false) + setAttributes(enabled) + } + } finally { + setLoading(false) + } + } + + load() + }, [activeGroup?.scopeId]) + + // ============================================================================ + // VALUE HELPERS + // ============================================================================ + + const currentValues: EventCustomAttributeValue[] = formData.customAttributes || [] + + const getTextValue = (attr: CustomAttributeConfig): string => + currentValues.find(v => v.attributeId === attr.attributeId)?.value ?? '' + + const getBoolValue = (attr: CustomAttributeConfig): boolean => + currentValues.find(v => v.attributeId === attr.attributeId)?.value === 'true' + + const getSingleSelectValue = (attr: CustomAttributeConfig): string => + currentValues.find(v => v.attributeId === attr.attributeId)?.value ?? '' + + const getMultiSelectValues = (attr: CustomAttributeConfig): EventCustomAttributeValue[] => + currentValues.filter(v => v.attributeId === attr.attributeId) + + // ============================================================================ + // UPDATE HELPERS + // ============================================================================ + + const updateTextValue = (attr: CustomAttributeConfig, value: string) => { + const others = currentValues.filter(v => v.attributeId !== attr.attributeId) + const entry: EventCustomAttributeValue[] = value + ? [{ attributeId: attr.attributeId, attribute: attr.name, value }] + : [] + updateFormData({ customAttributes: [...others, ...entry] }) + } + + const updateBoolValue = (attr: CustomAttributeConfig, value: boolean) => { + const others = currentValues.filter(v => v.attributeId !== attr.attributeId) + updateFormData({ + customAttributes: [ + ...others, + { attributeId: attr.attributeId, attribute: attr.name, value: String(value) }, + ], + }) + } + + const updateSingleSelectValue = (attr: CustomAttributeConfig, selectedValue: string) => { + const others = currentValues.filter(v => v.attributeId !== attr.attributeId) + if (!selectedValue) { + updateFormData({ customAttributes: others }) + return + } + const opt = attr.values.find(v => v.value === selectedValue) + if (!opt) return + updateFormData({ + customAttributes: [ + ...others, + { + attributeId: attr.attributeId, + attribute: attr.name, + valueId: opt.valueId, + value: opt.value, + displayOrder: opt.displayOrder, + }, + ], + }) + } + + const updateMultiSelectValue = useCallback((attr: CustomAttributeConfig, selectedValues: string[]) => { + const others = currentValues.filter(v => v.attributeId !== attr.attributeId) + const newEntries: EventCustomAttributeValue[] = selectedValues.map((sv, i) => { + const opt = attr.values.find(v => v.value === sv) + return { + attributeId: attr.attributeId, + attribute: attr.name, + valueId: opt?.valueId, + value: sv, + displayOrder: i, // user-defined order + } + }) + updateFormData({ customAttributes: [...others, ...newEntries] }) + }, [currentValues, updateFormData]) + + // ============================================================================ + // RENDER + // ============================================================================ + + const renderInput = (attr: CustomAttributeConfig) => { + switch (attr.inputType) { + case 'text': + return ( + updateTextValue(attr, v)} + /> + ) + + case 'boolean': + return ( + updateBoolValue(attr, v)} + > + {attr.name} + + ) + + case 'single-select': + return ( + updateSingleSelectValue(attr, String(key))} + > + {attr.values + .slice() + .sort((a, b) => a.displayOrder - b.displayOrder) + .map(v => ( + {v.label || v.value} + )) + } + + ) + + case 'multi-select': + return ( + updateMultiSelectValue(attr, selectedValues)} + /> + ) + + default: + return null + } + } + + return ( + +
+
+ + Custom Attributes + + {loading && } +
+ + These fields support advanced custom downstream integrations and data mapping purposes. + + + {!loading && attributes.length === 0 && ( + No active custom attributes are configured for this scope. + )} + + {attributes.map((attr, index) => ( + + {index > 0 && } +
+ {attr.name} + {renderInput(attr)} +
+
+ ))} +
+
+ ) +} diff --git a/web-src/src/pages/EventForm/EventForm.tsx b/web-src/src/pages/EventForm/EventForm.tsx index 835bef2..9e1dbf8 100644 --- a/web-src/src/pages/EventForm/EventForm.tsx +++ b/web-src/src/pages/EventForm/EventForm.tsx @@ -32,20 +32,21 @@ import { configService } from '../../services/configService' import { IMS } from '../../types' import { FormWizard, WizardStep, BlurredLoadingOverlay, FormCard, HistoryTimeline } from '../../components/shared' import { - EventFormatComponent, - EventTagsComponent, - EventInfoComponent, - AgendaComponent, - VenueComponent, - SpeakersComponent, - SponsorsComponent, - EventImagesComponent, - RegistrationConfigComponent, + EventFormatComponent, + EventTagsComponent, + EventInfoComponent, + AgendaComponent, + VenueComponent, + SpeakersComponent, + SponsorsComponent, + EventImagesComponent, + RegistrationConfigComponent, PageMetadataComponent, PromotionalContentComponent, MarketoIntegrationComponent, SessionManagementComponent, - VideoContentComponent + VideoContentComponent, + CustomAttributesComponent, } from './index' import { mapApiResponseToFormData } from '../../utils/eventFormMappers' import { useEventFeatureFlags } from '../../hooks/useEventTypeFeatures' @@ -919,6 +920,8 @@ const EventFormInner: React.FC = ({ ims: _ims }) => { )} + + ) diff --git a/web-src/src/pages/EventForm/index.ts b/web-src/src/pages/EventForm/index.ts index a7a5afc..447a4ad 100644 --- a/web-src/src/pages/EventForm/index.ts +++ b/web-src/src/pages/EventForm/index.ts @@ -21,3 +21,4 @@ export { PromotionalContentComponent } from './PromotionalContentComponent' export { MarketoIntegrationComponent } from './MarketoIntegrationComponent' export { default as SessionManagementComponent } from './SessionManagement/index' export { VideoContentComponent } from './VideoContentComponent' +export { CustomAttributesComponent } from './CustomAttributesComponent' diff --git a/web-src/src/types/domain.ts b/web-src/src/types/domain.ts index 081d807..ff9a077 100644 --- a/web-src/src/types/domain.ts +++ b/web-src/src/types/domain.ts @@ -634,6 +634,15 @@ export interface EventFormData { localizations?: Record localizationOverrides?: Record metadata?: Record + customAttributes?: EventCustomAttributeValue[] +} + +export interface EventCustomAttributeValue { + attributeId: string + attribute: string + valueId?: string + value: string + displayOrder?: number } // Agenda Item diff --git a/web-src/src/utils/dataFilters.ts b/web-src/src/utils/dataFilters.ts index 6151fa4..2f44421 100644 --- a/web-src/src/utils/dataFilters.ts +++ b/web-src/src/utils/dataFilters.ts @@ -144,6 +144,7 @@ export const EVENT_DATA_FILTER: DataFilter = { video: { type: 'object', localizable: false, cloneable: true, submittable: true, ref: VIDEO_DATA_REF_FILTER }, registration: { type: 'object', localizable: false, cloneable: true, submittable: true, ref: REGISTRATION_DATA_REF_FILTER }, marketoIntegration: { type: 'object', localizable: false, cloneable: false, submittable: true, ref: MARKETO_INTEGRATION_DATA_REF_FILTER }, + customAttributes: { type: 'array', localizable: false, cloneable: false, submittable: true }, } // ============================================================================ diff --git a/web-src/src/utils/eventFormMappers.ts b/web-src/src/utils/eventFormMappers.ts index 9dc1097..c1363af 100644 --- a/web-src/src/utils/eventFormMappers.ts +++ b/web-src/src/utils/eventFormMappers.ts @@ -178,5 +178,6 @@ export function mapApiResponseToFormData(event: EventApiResponse, locale: string }), marketoIntegration: event.marketoIntegration, video: event.video, + customAttributes: event.customAttributes || [], } } From 433dfe1141327cf9ecc02852d699f7c9cc8c992a Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Wed, 8 Apr 2026 23:30:46 -0700 Subject: [PATCH 06/23] feat(config): update customAttributes contract and migrate RSVP fields to scope configs API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update scope-configs integration guide: options now use {value, label} format, add localization merge patterns and option display logic notes - Migrate RegistrationFieldsComponent from fetching external JSON configs to cachedApi.getConfigsForScope, using RsvpFormField labels directly - Update RegistrationConfigComponent prop name cloudType → isExperienceCloud Co-Authored-By: Claude Sonnet 4.6 --- .../scope-configs-fe-integration-guide.md | 136 ++++++++----- .../EventForm/RegistrationConfigComponent.tsx | 2 +- .../EventForm/RegistrationFieldsComponent.tsx | 178 +++++++----------- 3 files changed, 158 insertions(+), 158 deletions(-) diff --git a/.claude/temp-specs/scope-configs-fe-integration-guide.md b/.claude/temp-specs/scope-configs-fe-integration-guide.md index de773c9..64ed1cb 100644 --- a/.claude/temp-specs/scope-configs-fe-integration-guide.md +++ b/.claude/temp-specs/scope-configs-fe-integration-guide.md @@ -89,12 +89,12 @@ Config routes require `config:read`, `config:write`, or `config:delete` permissi "required": true, "displayAs": "dropdown", "options": [ - "Art or Creative Director", - "Animator", - "Developer", - "Marketer", - "Student", - "Other" + { "value": "Art or Creative Director", "label": "Art or Creative Director" }, + { "value": "Animator", "label": "Animator" }, + { "value": "Developer", "label": "Developer" }, + { "value": "Marketer", "label": "Marketer" }, + { "value": "Student", "label": "Student" }, + { "value": "Other", "label": "Other" } ], "rules": "", "default": "" @@ -105,7 +105,13 @@ Config routes require `config:read`, `config:write`, or `config:delete` permissi "type": "multi-select", "required": false, "displayAs": "checkbox", - "options": ["Acrobat Pro", "Adobe Express", "Photoshop", "Illustrator", "Premiere Pro"], + "options": [ + { "value": "Acrobat Pro", "label": "Acrobat Pro" }, + { "value": "Adobe Express", "label": "Adobe Express" }, + { "value": "Photoshop", "label": "Photoshop" }, + { "value": "Illustrator", "label": "Illustrator" }, + { "value": "Premiere Pro", "label": "Premiere Pro" } + ], "rules": "", "default": "" } @@ -115,7 +121,14 @@ Config routes require `config:read`, `config:write`, or `config:delete` permissi "rsvpFormFields": [ { "field": "firstName", "label": "Prénom", "placeholder": "Prénom" }, { "field": "email", "label": "Courriel", "placeholder": "Courriel" }, - { "field": "jobTitle", "label": "Titre du poste", "options": ["Directeur artistique", "Animateur", "Développeur", "Spécialiste marketing", "Étudiant", "Autre"] }, + { "field": "jobTitle", "label": "Titre du poste", "options": [ + { "value": "Art or Creative Director", "label": "Directeur artistique" }, + { "value": "Animator", "label": "Animateur" }, + { "value": "Developer", "label": "Développeur" }, + { "value": "Marketer", "label": "Spécialiste marketing" }, + { "value": "Student", "label": "Étudiant" }, + { "value": "Other", "label": "Autre" } + ]}, { "field": "productsOfInterest", "label": "Produits d'intérêt" } ] } @@ -153,9 +166,9 @@ Config routes require `config:read`, `config:write`, or `config:delete` permissi "inputType": "single-select", "enabled": true, "values": [ - { "valueId": "a1b2c3d4-0001", "value": "Photoshop", "displayOrder": 0 }, - { "valueId": "a1b2c3d4-0002", "value": "Illustrator", "displayOrder": 1 }, - { "valueId": "a1b2c3d4-0003", "value": "Premiere Pro", "displayOrder": 2 } + { "valueId": "a1b2c3d4-0001", "value": "Photoshop", "label": "Photoshop", "displayOrder": 0 }, + { "valueId": "a1b2c3d4-0002", "value": "Illustrator", "label": "Illustrator", "displayOrder": 1 }, + { "valueId": "a1b2c3d4-0003", "value": "Premiere Pro", "label": "Premiere Pro", "displayOrder": 2 } ] }, { @@ -164,9 +177,9 @@ Config routes require `config:read`, `config:write`, or `config:delete` permissi "inputType": "multi-select", "enabled": true, "values": [ - { "valueId": "b2c3d4e5-0001", "value": "Blog Post", "displayOrder": 0 }, - { "valueId": "b2c3d4e5-0002", "value": "Social Media", "displayOrder": 1 }, - { "valueId": "b2c3d4e5-0003", "value": "Video", "displayOrder": 2 } + { "valueId": "b2c3d4e5-0001", "value": "Blog Post", "label": "Blog Post", "displayOrder": 0 }, + { "valueId": "b2c3d4e5-0002", "value": "Social Media", "label": "Social Media", "displayOrder": 1 }, + { "valueId": "b2c3d4e5-0003", "value": "Video", "label": "Video", "displayOrder": 2 } ] }, { @@ -212,6 +225,7 @@ Config routes require `config:read`, `config:write`, or `config:delete` permissi ### POST /v1/scopes/{scopeId}/configs — Create config **Request:** + ```json { "type": "rsvp", @@ -222,6 +236,7 @@ Config routes require `config:read`, `config:write`, or `config:delete` permissi ``` **Response (201):** + ```json { "configId": "7a8b9c0d-1234-5678-9abc-def012345678", @@ -238,6 +253,7 @@ Config routes require `config:read`, `config:write`, or `config:delete` permissi ### POST — Create custom-attributes config **Request:** + ```json { "type": "custom-attributes", @@ -257,6 +273,7 @@ Config routes require `config:read`, `config:write`, or `config:delete` permissi ``` **Response (201):** + ```json { "configId": "e5f6a7b8-1234-5678-9abc-def012345678", @@ -297,16 +314,18 @@ Note: `attributeId` and `valueId` should be generated client-side (UUID) before { "message": "Human-readable error description" } ``` -| Status | Meaning | Example | -|--------|---------|---------| -| 200 | Success | — | -| 201 | Created | — | -| 204 | Deleted (empty body) | — | -| 400 | Bad request | `"type is required when creating a config"` | -| 400 | Platform restriction | `"Configs cannot be created at the platform scope level."` | -| 403 | Forbidden | `"Insufficient permissions"` | -| 404 | Not found | `"Config not found"` / `"Scope not found"` | -| 409 | Duplicate | `"A config with type 'rsvp' already exists for this scope"` | + +| Status | Meaning | Example | +| ------ | -------------------- | ----------------------------------------------------------- | +| 200 | Success | — | +| 201 | Created | — | +| 204 | Deleted (empty body) | — | +| 400 | Bad request | `"type is required when creating a config"` | +| 400 | Platform restriction | `"Configs cannot be created at the platform scope level."` | +| 403 | Forbidden | `"Insufficient permissions"` | +| 404 | Not found | `"Config not found"` / `"Scope not found"` | +| 409 | Duplicate | `"A config with type 'rsvp' already exists for this scope"` | + --- @@ -328,9 +347,17 @@ if (rsvpConfig) { // field.type → "select" | "multi-select" | "text" | "email" | "phone" // field.displayAs → "dropdown" | "radio" | "checkbox" // field.required → true/false - // field.options → ["Option A", "Option B", ...] + // field.options → [{ value, label }, ...] for select types + // option.value → stored in DB (locale-independent key) + // option.label → displayed to user (localizable, falls back to value) // field.default → default value // field.rules → "full-width" etc. + + // Rendering select options: + // field.options.forEach(opt => { + // const display = opt.label || opt.value; // label for display + // const stored = opt.value; // value for DB + // }); }); } ``` @@ -346,11 +373,19 @@ const localeOverrides = rsvpConfig.localizations?.[userLocale]?.rsvpFormFields | const localizedFields = baseFields.map(field => { const override = localeOverrides.find(o => o.field === field.field); + // Merge localized labels into options — value stays the same, label gets translated + let localizedOptions = field.options; + if (override?.options) { + localizedOptions = (field.options || []).map(opt => { + const locOpt = override.options.find(lo => lo.value === opt.value); + return locOpt ? { ...opt, label: locOpt.label } : opt; + }); + } return { ...field, label: override?.label || field.label, placeholder: override?.placeholder || field.placeholder, - options: override?.options || field.options + options: localizedOptions }; }); ``` @@ -458,10 +493,12 @@ const customAttrsConfig = allConfigs.find(c => c.type === 'custom-attributes'); ## Hierarchy Behavior -| Merge Strategy | Behavior | -|---------------|----------| + +| Merge Strategy | Behavior | +| ------------------------- | --------------------------------------------------------------------------------- | | **Full replace** per type | If team scope has a `rsvp` config, it completely replaces the org's `rsvp` config | + Each config in the response includes a `scopeId` field indicating where it was originally defined. If `scopeId` differs from the queried scope, it's inherited from a parent. ### Overriding Inherited Configs @@ -500,31 +537,37 @@ await fetch(`/v1/scopes/${teamScopeId}/configs`, { ### RSVP Form Field `type` Values -| Value | Render As | -|-------|-----------| -| `text` | Text input | -| `email` | Email input (with validation) | -| `phone` | Phone input | -| `select` | Dropdown or radio buttons (check `displayAs`) | + +| Value | Render As | +| -------------- | ------------------------------------------------ | +| `text` | Text input | +| `email` | Email input (with validation) | +| `phone` | Phone input | +| `select` | Dropdown or radio buttons (check `displayAs`) | | `multi-select` | Multi-dropdown or checkboxes (check `displayAs`) | + ### RSVP Form Field `displayAs` Values -| Value | Applies To | Render As | -|-------|-----------|-----------| -| `dropdown` | `select` | Standard dropdown | -| `radio` | `select` | Radio button group | + +| Value | Applies To | Render As | +| ---------- | -------------- | --------------------- | +| `dropdown` | `select` | Standard dropdown | +| `radio` | `select` | Radio button group | | `dropdown` | `multi-select` | Multi-select dropdown | -| `checkbox` | `multi-select` | Checkbox group | +| `checkbox` | `multi-select` | Checkbox group | + ### Custom Attribute `inputType` Values -| Value | Render As | Values Array | -|-------|-----------|-------------| -| `text` | Text input | Not used | -| `boolean` | Checkbox/toggle | Not used | -| `single-select` | Dropdown | Required — list of options | -| `multi-select` | Multi-select | Required — list of options | + +| Value | Render As | Values Array | +| --------------- | --------------- | -------------------------- | +| `text` | Text input | Not used | +| `boolean` | Checkbox/toggle | Not used | +| `single-select` | Dropdown | Required — list of options | +| `multi-select` | Multi-select | Required — list of options | + --- @@ -536,4 +579,5 @@ await fetch(`/v1/scopes/${teamScopeId}/configs`, { - RSVP labels and options support **localization** via the `localizations` object - Custom attributes with `enabled: false` should be hidden from the events console - One config per type per scope — duplicates return 409 -- Inherited configs can be **overridden** at child scopes by creating a config with the same type — the child's config fully replaces the parent's \ No newline at end of file +- Inherited configs can be **overridden** at child scopes by creating a config with the same type — the child's config fully replaces the parent's +- **Options use `{ value, label }` format** — `value` is the locale-independent key stored in DB, `label` is the display text shown to users. Localizations override `label` while keeping `value` unchanged. FE display logic: `option.label || option.value` diff --git a/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx b/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx index fa77e24..3f5bb8a 100644 --- a/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx +++ b/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx @@ -252,7 +252,7 @@ export const RegistrationConfigComponent: React.FC = () => { {/* Registration Fields Configuration */}
*/ @@ -9,32 +9,22 @@ import { HeadingWithTooltip } from '../../components/shared' import { COLORS, SURFACES } from '../../styles/designSystem' import OpenIn from '@react-spectrum/s2/icons/OpenIn' import Move from '@react-spectrum/s2/icons/Move' - -/** - * Configuration field structure from the JSON configs - */ -interface RsvpConfigField { - Field: string - Type: string - Required?: string -} - -interface RsvpConfig { - cloudType: string - config: RsvpConfigField[] | null -} +import { useGroup } from '../../contexts/GroupContext' +import { cachedApi } from '../../services/api' +import type { RsvpFormField, RsvpScopeConfig } from '../../types/configApi' /** * Extended field with display info */ interface DisplayField { fieldName: string + label: string isMandated: boolean originalIndex: number } interface RegistrationFieldsComponentProps { - cloudType: 'CreativeCloud' | 'ExperienceCloud' + isExperienceCloud: boolean eventType: 'InPerson' | 'Virtual' visibleFields: string[] requiredFields: string[] @@ -46,48 +36,8 @@ interface RegistrationFieldsComponentProps { onMarketoFormUrlChange: (url: string) => void } -/** - * Converts a camelCase or PascalCase string into an uppercase string with spaces between words. - */ -const convertString = (input: string): string => { - const parts = input.replace(/([a-z])([A-Z])/g, '$1 $2') - return parts.toUpperCase() -} - -/** - * Fetches RSVP form configurations for all supported clouds - */ -const fetchRsvpFormConfigs = async (): Promise => { - const SUPPORTED_CLOUDS = [ - { id: 'CreativeCloud', name: 'Creative Cloud' }, - { id: 'ExperienceCloud', name: 'Experience Cloud' } - ] - - return Promise.all( - SUPPORTED_CLOUDS.map(async ({ id }) => { - try { - const response = await fetch(`https://www.adobe.com/event-libs/assets/configs/rsvp/${id.toLowerCase()}.json`) - if (!response.ok) { - console.error(`Failed to fetch RSVP config for ${id}: ${response.status} ${response.statusText}`) - return { cloudType: id, config: null } - } - const data = await response.json() - console.log(`Fetched RSVP config for ${id}:`, data) - - // Handle different possible JSON structures - const config = Array.isArray(data) ? data : (data.data || data.fields || data.config || null) - - return { cloudType: id, config } - } catch (error) { - console.error(`Failed to fetch RSVP config for ${id}:`, error) - return { cloudType: id, config: null } - } - }) - ) -} - export const RegistrationFieldsComponent: React.FC = ({ - cloudType, + isExperienceCloud, eventType, visibleFields, requiredFields, @@ -98,21 +48,33 @@ export const RegistrationFieldsComponent: React.FC { - const [configs, setConfigs] = useState([]) + const { activeGroup } = useGroup() + const [fields, setFields] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - + // Drag and drop state const [draggedIndex, setDraggedIndex] = useState(null) const [dragOverIndex, setDragOverIndex] = useState(null) - // Fetch configs on mount + // Fetch RSVP config for the current scope on mount useEffect(() => { - const loadConfigs = async () => { + const scopeId = activeGroup?.scopeId + if (!scopeId) { + setLoading(false) + return + } + + const loadFields = async () => { try { setLoading(true) - const fetchedConfigs = await fetchRsvpFormConfigs() - setConfigs(fetchedConfigs) + const result = await cachedApi.getConfigsForScope(scopeId, 'rsvp') + if ('error' in result) { + setError('Failed to load registration field configurations') + return + } + const rsvpConfig = result.find(c => c.type === 'rsvp') as RsvpScopeConfig | undefined + setFields(rsvpConfig?.rsvpFormFields ?? []) setError(null) } catch (err) { setError('Failed to load registration field configurations') @@ -122,21 +84,18 @@ export const RegistrationFieldsComponent: React.FC c.cloudType === cloudType) - const currentConfig = Array.isArray(cloudConfig?.config) ? cloudConfig.config : [] - - // Filter out items with null-ish Field attribute and submit buttons - const validFields = currentConfig.filter((f) => f.Field && f.Field.trim() !== '' && f.Type !== 'submit') - const mandatedFieldNames = validFields.filter((f) => f.Required === 'x').map((f) => f.Field) - + loadFields() + }, [activeGroup?.scopeId]) + + // Filter out submit-type fields + const validFields = fields.filter(f => f.field) + const mandatedFieldNames = validFields.filter(f => f.required).map(f => f.field) + // Build display fields list with original order preserved const allDisplayFields: DisplayField[] = validFields.map((f, idx) => ({ - fieldName: f.Field, - isMandated: f.Required === 'x', + fieldName: f.field, + label: f.label, + isMandated: f.required, originalIndex: idx })) @@ -145,15 +104,15 @@ export const RegistrationFieldsComponent: React.FC { const aIsSelected = visibleFields.includes(a.fieldName) const bIsSelected = visibleFields.includes(b.fieldName) - + if (aIsSelected && !bIsSelected) return -1 if (!aIsSelected && bIsSelected) return 1 - + // Both selected: sort by position in visibleFields array if (aIsSelected && bIsSelected) { return visibleFields.indexOf(a.fieldName) - visibleFields.indexOf(b.fieldName) } - + // Both unselected: maintain original config order return a.originalIndex - b.originalIndex }) @@ -167,12 +126,12 @@ export const RegistrationFieldsComponent: React.FC 0) { const newVisibleFields = [...visibleFields, ...missingVisibleMandated] onVisibleFieldsChange(newVisibleFields) - + // Also ensure requiredFields is ordered consistently with newVisibleFields const missingRequiredMandated = mandatedFieldNames.filter((f) => !requiredFields.includes(f)) if (missingRequiredMandated.length > 0) { // Build required array in the same order as visible - const newRequiredFields = newVisibleFields.filter((f) => + const newRequiredFields = newVisibleFields.filter((f) => requiredFields.includes(f) || missingRequiredMandated.includes(f) ) onRequiredFieldsChange(newRequiredFields) @@ -182,7 +141,7 @@ export const RegistrationFieldsComponent: React.FC !requiredFields.includes(f)) if (missingRequiredMandated.length > 0) { // Build required array in the same order as visible - const newRequiredFields = visibleFields.filter((f) => + const newRequiredFields = visibleFields.filter((f) => requiredFields.includes(f) || missingRequiredMandated.includes(f) ) onRequiredFieldsChange(newRequiredFields) @@ -198,7 +157,7 @@ export const RegistrationFieldsComponent: React.FC { e.preventDefault() - + if (draggedIndex === null || draggedIndex === dropDisplayIndex) { setDraggedIndex(null) setDragOverIndex(null) @@ -231,7 +190,7 @@ export const RegistrationFieldsComponent: React.FC requiredFields.includes(f)) onRequiredFieldsChange(newRequiredFields) - + setDraggedIndex(null) setDragOverIndex(null) } @@ -273,7 +231,7 @@ export const RegistrationFieldsComponent: React.FC requiredFields.includes(f)) - if (newRequiredFields.length !== requiredFields.length || + if (newRequiredFields.length !== requiredFields.length || !newRequiredFields.every((f, i) => f === requiredFields[i])) { onRequiredFieldsChange(newRequiredFields) } @@ -299,18 +257,18 @@ export const RegistrationFieldsComponent: React.FC { - // Format mandated fields for display - const mandatedFieldsDisplay = mandatedFieldNames.map((field) => convertString(field)).join(', ') - const cloudName = cloudType === 'CreativeCloud' ? 'Creative Cloud' : 'Experience Cloud' - + const mandatedLabels = mandatedFieldNames + .map(name => allDisplayFields.find(f => f.fieldName === name)?.label ?? name) + .join(', ') + return (
{mandatedFieldNames.length > 0 && ( - Note: {cloudName} required fields include {mandatedFieldsDisplay} + Note: required fields include {mandatedLabels} )} - +
- {/* Header row - 4 columns now with drag handle */} -
{sortedDisplayFields.map((displayField, displayIndex) => { - const { fieldName, isMandated } = displayField + const { fieldName, label, isMandated } = displayField const isVisible = visibleFields.includes(fieldName) const isRequired = requiredFields.includes(fieldName) const isDragging = draggedIndex === displayIndex @@ -365,13 +323,13 @@ export const RegistrationFieldsComponent: React.FC - {convertString(fieldName)} + {label} {isMandated && ( - @@ -507,4 +464,3 @@ export const RegistrationFieldsComponent: React.FC ) } - From 63945bcd9f77ef7bff7744de5b0655719c29ca07 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Thu, 9 Apr 2026 10:54:07 -0700 Subject: [PATCH 07/23] fix(config): wire RSVP edit dialog to active locale for localizable fields When a locale is selected in the table toolbar, the Edit RSVP Config dialog now shows and edits locale-specific labels, placeholders, and option labels instead of base values. Structural controls (add/remove field, add/remove option, option values) are hidden in locale mode. Co-Authored-By: Claude Sonnet 4.6 --- .../ConfigManagement/ConfigManagement.tsx | 217 ++++++++++++------ 1 file changed, 146 insertions(+), 71 deletions(-) diff --git a/web-src/src/pages/ConfigManagement/ConfigManagement.tsx b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx index 2d2d7ab..ef21691 100644 --- a/web-src/src/pages/ConfigManagement/ConfigManagement.tsx +++ b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx @@ -337,6 +337,56 @@ export const ConfigManagement: React.FC = () => { setIsRsvpFormOpen(true) }, []) + // Helpers for reading/writing locale overrides inside the RSVP dialog. + // These use `activeLocale` (the table toolbar locale switcher) so the dialog + // reflects whichever locale is selected on the page when it opens. + const getDialogLocaleFieldValue = useCallback((fieldName: string, key: 'label' | 'placeholder'): string => { + if (!activeLocale) return '' + return rsvpLocalizations[activeLocale]?.rsvpFormFields?.find(f => f.field === fieldName)?.[key] ?? '' + }, [activeLocale, rsvpLocalizations]) + + const getDialogLocaleOptionLabel = useCallback((fieldName: string, optValue: string): string => { + if (!activeLocale) return '' + return rsvpLocalizations[activeLocale]?.rsvpFormFields?.find(f => f.field === fieldName)?.options?.find(o => o.value === optValue)?.label ?? '' + }, [activeLocale, rsvpLocalizations]) + + const setDialogLocaleFieldValue = useCallback((fieldName: string, updates: Partial) => { + if (!activeLocale) return + const locale = activeLocale + setRsvpLocalizations(prev => { + const localeData = { ...(prev[locale] ?? { rsvpFormFields: [] }) } + const fields = [...localeData.rsvpFormFields] + const idx = fields.findIndex(f => f.field === fieldName) + const entry = { ...(idx >= 0 ? fields[idx] : { field: fieldName }), ...updates } + if (idx >= 0) fields[idx] = entry + else fields.push(entry) + return { ...prev, [locale]: { rsvpFormFields: fields } } + }) + }, [activeLocale]) + + const setDialogLocaleOptionLabel = useCallback((fieldName: string, optValue: string, newLabel: string) => { + if (!activeLocale) return + const locale = activeLocale + setRsvpLocalizations(prev => { + const localeData = { ...(prev[locale] ?? { rsvpFormFields: [] }) } + const fields = [...localeData.rsvpFormFields] + const idx = fields.findIndex(f => f.field === fieldName) + const existing = idx >= 0 ? fields[idx] : { field: fieldName } + const baseField = rsvpFormFields.find(f => f.field === fieldName) + const baseOptions = baseField?.options ?? [] + const currentOptions = existing.options ?? [] + const updatedOptions = baseOptions.map(o => { + if (o.value === optValue) return { value: o.value, label: newLabel } + const cur = currentOptions.find(co => co.value === o.value) + return { value: o.value, label: cur?.label ?? '' } + }).filter(o => o.label.trim()) + const entry = { ...existing, options: updatedOptions.length > 0 ? updatedOptions : undefined } + if (idx >= 0) fields[idx] = entry + else fields.push(entry) + return { ...prev, [locale]: { rsvpFormFields: fields } } + }) + }, [activeLocale, rsvpFormFields]) + const handleSaveRsvpConfig = useCallback(async () => { if (!selectedScopeId) return const validFields = rsvpFormFields.filter(f => f.field.trim() && f.label.trim()) @@ -1368,7 +1418,7 @@ export const ConfigManagement: React.FC = () => {
- + {/* Tab content */} {selectedScopeId ? ( @@ -1760,19 +1810,28 @@ export const ConfigManagement: React.FC = () => {
Form Fields - + {!(activeLocale && editingRsvpConfig) && ( + + )}
+ {(activeLocale && editingRsvpConfig) && ( +
+ + Locale: {activeLocale} — Label, Placeholder, and Option Labels save as locale overrides. All other fields update the base definition. + +
+ )}
{rsvpFormFields.map((field, index) => { const hasOptions = field.type === 'select' || field.type === 'multi-select' @@ -1809,14 +1868,16 @@ export const ConfigManagement: React.FC = () => {
)}
- { e.continuePropagation?.(); setRsvpFormFields(prev => prev.filter((_, i) => i !== index)) }} - isDisabled={rsvpFormFields.length <= 1} - > - - + {!(activeLocale && editingRsvpConfig) && ( + { e.continuePropagation?.(); setRsvpFormFields(prev => prev.filter((_, i) => i !== index)) }} + isDisabled={rsvpFormFields.length <= 1} + > + + + )}
{/* Body — always shown for non-select types; toggled for select/multi-select */} {isExpanded && ( @@ -1833,23 +1894,29 @@ export const ConfigManagement: React.FC = () => { isRequired /> setRsvpFormFields(prev => { - const copy = [...prev] - copy[index] = { ...copy[index], label: v } - return copy - })} - isRequired + label={(activeLocale && editingRsvpConfig) ? `Label (${activeLocale})` : 'Label'} + value={(activeLocale && editingRsvpConfig) ? getDialogLocaleFieldValue(field.field, 'label') : field.label} + onChange={(activeLocale && editingRsvpConfig) + ? (v) => setDialogLocaleFieldValue(field.field, { label: v || undefined }) + : (v) => setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], label: v } + return copy + }) + } + isRequired={!(activeLocale && editingRsvpConfig)} /> setRsvpFormFields(prev => { - const copy = [...prev] - copy[index] = { ...copy[index], placeholder: v } - return copy - })} + label={(activeLocale && editingRsvpConfig) ? `Placeholder (${activeLocale})` : 'Placeholder'} + value={(activeLocale && editingRsvpConfig) ? getDialogLocaleFieldValue(field.field, 'placeholder') : field.placeholder} + onChange={(activeLocale && editingRsvpConfig) + ? (v) => setDialogLocaleFieldValue(field.field, { placeholder: v || undefined }) + : (v) => setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], placeholder: v } + return copy + }) + } /> = () => { Options{field.options.length > 0 ? ` (${field.options.length})` : ''} - setRsvpFormFields(prev => { - const copy = [...prev] - copy[index] = { ...copy[index], options: [...copy[index].options, { value: '', label: '' }] } - return copy - })} - > - - Add Option - + {!(activeLocale && editingRsvpConfig) && ( + setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], options: [...copy[index].options, { value: '', label: '' }] } + return copy + })} + > + + Add Option + + )}
0 @@ -1931,6 +2000,7 @@ export const ConfigManagement: React.FC = () => { setRsvpFormFields(prev => { const copy = [...prev] const opts = [...copy[index].options] @@ -1941,31 +2011,36 @@ export const ConfigManagement: React.FC = () => { styles={style({ flexGrow: 1 })} /> setRsvpFormFields(prev => { - const copy = [...prev] - const opts = [...copy[index].options] - opts[optIdx] = { ...opts[optIdx], label: v } - copy[index] = { ...copy[index], options: opts } - return copy - })} + label={(activeLocale && editingRsvpConfig) ? `Label (${activeLocale})` : 'Label'} + value={(activeLocale && editingRsvpConfig) ? getDialogLocaleOptionLabel(field.field, opt.value) : opt.label} + onChange={(activeLocale && editingRsvpConfig) + ? (v) => setDialogLocaleOptionLabel(field.field, opt.value, v) + : (v) => setRsvpFormFields(prev => { + const copy = [...prev] + const opts = [...copy[index].options] + opts[optIdx] = { ...opts[optIdx], label: v } + copy[index] = { ...copy[index], options: opts } + return copy + }) + } styles={style({ flexGrow: 1 })} /> - setRsvpFormFields(prev => { - const copy = [...prev] - copy[index] = { - ...copy[index], - options: copy[index].options.filter((_, oi) => oi !== optIdx), - } - return copy - })} - > - - + {!(activeLocale && editingRsvpConfig) && ( + setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { + ...copy[index], + options: copy[index].options.filter((_, oi) => oi !== optIdx), + } + return copy + })} + > + + + )}
))} {field.options.length === 0 && ( From df1c735809aa87d8bbe00d8473bce68d650648bd Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Thu, 9 Apr 2026 10:55:53 -0700 Subject: [PATCH 08/23] fix(event-form): prevent custom attribute pickers from stretching full width Co-Authored-By: Claude Sonnet 4.6 --- web-src/src/pages/EventForm/CustomAttributesComponent.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web-src/src/pages/EventForm/CustomAttributesComponent.tsx b/web-src/src/pages/EventForm/CustomAttributesComponent.tsx index fe87845..2a8e96d 100644 --- a/web-src/src/pages/EventForm/CustomAttributesComponent.tsx +++ b/web-src/src/pages/EventForm/CustomAttributesComponent.tsx @@ -102,6 +102,7 @@ const MultiSelectRepeater: React.FC = ({ attr, values, placeholder={`Select ${attr.name}`} selectedKey={row.value || null} onSelectionChange={(key) => handleChange(row.id, String(key))} + styles={style({ flexGrow: 1 })} > {getAvailableOptions(row.value).map(opt => ( {opt.label || opt.value} @@ -276,6 +277,7 @@ export const CustomAttributesComponent: React.FC = () => { label={attr.name} selectedKey={getSingleSelectValue(attr) || null} onSelectionChange={(key) => updateSingleSelectValue(attr, String(key))} + styles={style({ alignSelf: 'start' })} > {attr.values .slice() From fa414ed267db1a2dd77d5f3b4273236e11d06076 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Fri, 10 Apr 2026 12:13:33 -0700 Subject: [PATCH 09/23] feat(event-form): add publish guard and remove agenda end time required indicator Remove isRequired from agenda end time fields (TimeField and DatePicker). Add a publish guard that validates all required fields across form steps before allowing publish, including custom attributes with isRequired. Shows a user-friendly dialog listing missing fields grouped by step. Co-Authored-By: Claude Opus 4.6 --- .../src/pages/EventForm/AgendaComponent.tsx | 2 - .../EventForm/CustomAttributesComponent.tsx | 10 +- web-src/src/pages/EventForm/EventForm.tsx | 55 ++++++++++- web-src/src/types/configApi.ts | 1 + web-src/src/types/domain.ts | 3 + web-src/src/utils/publishGuard.ts | 91 +++++++++++++++++++ 6 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 web-src/src/utils/publishGuard.ts diff --git a/web-src/src/pages/EventForm/AgendaComponent.tsx b/web-src/src/pages/EventForm/AgendaComponent.tsx index 5bc9279..49d5aa1 100644 --- a/web-src/src/pages/EventForm/AgendaComponent.tsx +++ b/web-src/src/pages/EventForm/AgendaComponent.tsx @@ -631,7 +631,6 @@ export const AgendaComponent: React.FC = () => { /> updateAgendaItem(index, { @@ -653,7 +652,6 @@ export const AgendaComponent: React.FC = () => { /> updateAgendaItem(index, { endDateTime: date?.toString() || '' })} diff --git a/web-src/src/pages/EventForm/CustomAttributesComponent.tsx b/web-src/src/pages/EventForm/CustomAttributesComponent.tsx index 2a8e96d..3c37c5e 100644 --- a/web-src/src/pages/EventForm/CustomAttributesComponent.tsx +++ b/web-src/src/pages/EventForm/CustomAttributesComponent.tsx @@ -160,6 +160,7 @@ export const CustomAttributesComponent: React.FC = () => { const config = result.find(c => c.type === 'custom-attributes') as CustomAttributesScopeConfig | undefined const enabled = (config?.attributes ?? []).filter(a => a.enabled !== false) setAttributes(enabled) + updateFormData({ _customAttributeConfigs: enabled }) } } finally { setLoading(false) @@ -256,6 +257,7 @@ export const CustomAttributesComponent: React.FC = () => { return ( updateTextValue(attr, v)} /> @@ -275,6 +277,7 @@ export const CustomAttributesComponent: React.FC = () => { return ( updateSingleSelectValue(attr, String(key))} styles={style({ alignSelf: 'start' })} @@ -327,7 +330,12 @@ export const CustomAttributesComponent: React.FC = () => { {index > 0 && }
- {attr.name} + + {attr.name} + {attr.isRequired && attr.inputType === 'multi-select' && ( + (Required) + )} + {renderInput(attr)}
diff --git a/web-src/src/pages/EventForm/EventForm.tsx b/web-src/src/pages/EventForm/EventForm.tsx index 9e1dbf8..88c87f4 100644 --- a/web-src/src/pages/EventForm/EventForm.tsx +++ b/web-src/src/pages/EventForm/EventForm.tsx @@ -55,6 +55,7 @@ import { useEventFormSave } from '../../hooks/useEventFormSave' import { useCustomDetailPagePath } from '../../hooks/useCustomDetailPagePath' import { COLORS, Z_INDEX, TYPOGRAPHY, SURFACES } from '../../styles/designSystem' import { getEspEnvParam } from '../../config/constants' +import { validateForPublish, PublishGuardResult } from '../../utils/publishGuard' // ============================================================================ // FORMAT SELECTION OVERLAY @@ -496,7 +497,8 @@ const EventFormInner: React.FC = ({ ims: _ims }) => { pendingAction: 'save' | 'publish' } | null>(null) const [isCheckingUrl, setIsCheckingUrl] = useState(false) - + const [publishGuardResult, setPublishGuardResult] = useState(null) + // Show toast when saveError changes useEffect(() => { if (saveError && saveError !== lastErrorShownRef.current) { @@ -770,18 +772,25 @@ const EventFormInner: React.FC = ({ ims: _ims }) => { * Handle Publish/Re-publish button click */ const handleComplete = useCallback(async () => { + // Validate all required fields across steps before publishing + const guardResult = validateForPublish({ formData, hasVenue }) + if (!guardResult.valid) { + setPublishGuardResult(guardResult) + return + } + const { proceed, extraPayload } = await checkUrlPatternBeforeSave('publish') if (!proceed) return persistToStorage() - + await publishEvent({ extraPayload, onSuccess: () => { setPublished(true) toast.success( isPublished ? 'Event re-published successfully!' : 'Event published successfully!', - { + { duration: 3000, action: { label: 'View Events', @@ -794,7 +803,7 @@ const EventFormInner: React.FC = ({ ims: _ims }) => { console.error('Failed to publish event:', error) } }) - }, [checkUrlPatternBeforeSave, publishEvent, persistToStorage, setPublished, navigate, toast, isPublished]) + }, [formData, hasVenue, checkUrlPatternBeforeSave, publishEvent, persistToStorage, setPublished, navigate, toast, isPublished]) /** * Handle max step change from FormWizard @@ -1138,6 +1147,44 @@ const EventFormInner: React.FC = ({ ims: _ims }) => {
+ {/* Publish guard — required fields missing dialog */} + { if (!open) setPublishGuardResult(null) }} + > +
+ + Required Fields Missing + + +
+ + Please complete the following required fields before publishing: + + {publishGuardResult?.missingByStep.map((group) => ( +
+ + {group.stepTitle} + +
    + {group.fields.map((field, i) => ( +
  • + {field.fieldLabel} +
  • + ))} +
+
+ ))} +
+
+ + + +
+ + {/* URL pattern check loading overlay */} {isCheckingUrl && ( diff --git a/web-src/src/types/configApi.ts b/web-src/src/types/configApi.ts index 8644948..1465b82 100644 --- a/web-src/src/types/configApi.ts +++ b/web-src/src/types/configApi.ts @@ -97,6 +97,7 @@ export interface CustomAttributeConfig { name: string inputType: CustomAttributeInputType enabled: boolean + isRequired?: boolean values: CustomAttributeValue[] } diff --git a/web-src/src/types/domain.ts b/web-src/src/types/domain.ts index ff9a077..813d079 100644 --- a/web-src/src/types/domain.ts +++ b/web-src/src/types/domain.ts @@ -635,6 +635,9 @@ export interface EventFormData { localizationOverrides?: Record metadata?: Record customAttributes?: EventCustomAttributeValue[] + + // Transient fields (not submitted to API, used for cross-component validation) + _customAttributeConfigs?: import('./configApi').CustomAttributeConfig[] } export interface EventCustomAttributeValue { diff --git a/web-src/src/utils/publishGuard.ts b/web-src/src/utils/publishGuard.ts new file mode 100644 index 0000000..37f314a --- /dev/null +++ b/web-src/src/utils/publishGuard.ts @@ -0,0 +1,91 @@ +/* + * Publish guard — validates all required fields across event form steps + * before allowing publish. Returns structured missing-field info for the dialog. + */ + +import type { EventFormData } from '../types/domain' +import type { CustomAttributeConfig } from '../types/configApi' + +export interface MissingField { + fieldLabel: string + context?: string +} + +export interface MissingFieldGroup { + stepTitle: string + fields: MissingField[] +} + +export interface PublishGuardResult { + valid: boolean + missingByStep: MissingFieldGroup[] +} + +export interface PublishGuardInput { + formData: EventFormData + hasVenue: boolean +} + +export function validateForPublish({ formData, hasVenue }: PublishGuardInput): PublishGuardResult { + const missingByStep: MissingFieldGroup[] = [] + + // ── Step 1: Basic Info ────────────────────────────────────────────── + const step1: MissingField[] = [] + + if (!formData.seriesId) { + step1.push({ fieldLabel: 'Series' }) + } + if (!formData.name?.trim()) { + step1.push({ fieldLabel: 'Event Title' }) + } + if (!formData.language) { + step1.push({ fieldLabel: 'Language' }) + } + if (!formData.shortDescription?.trim()) { + step1.push({ fieldLabel: 'Event Description for Events Hub and SEO' }) + } + if (!formData.startDateTime) { + step1.push({ fieldLabel: 'Start Date & Time' }) + } + if (!formData.endDateTime) { + step1.push({ fieldLabel: 'End Date & Time' }) + } + if (!formData.timezone?.trim()) { + step1.push({ fieldLabel: 'Timezone' }) + } + if (hasVenue && !formData.venue?.placeId) { + step1.push({ fieldLabel: 'Venue Location' }) + } + + if (step1.length > 0) { + missingByStep.push({ stepTitle: 'Basic Info', fields: step1 }) + } + + // ── Step 3: Additional Content (required custom attributes) ───────── + const configs: CustomAttributeConfig[] = formData._customAttributeConfigs ?? [] + const customValues = formData.customAttributes ?? [] + const step3: MissingField[] = [] + + for (const cfg of configs) { + if (!cfg.isRequired) continue + + // Boolean attributes always have a value (defaults to false), skip + if (cfg.inputType === 'boolean') continue + + const hasValue = customValues.some( + v => v.attributeId === cfg.attributeId && v.value?.trim() !== '' + ) + if (!hasValue) { + step3.push({ fieldLabel: cfg.name }) + } + } + + if (step3.length > 0) { + missingByStep.push({ stepTitle: 'Additional Content', fields: step3 }) + } + + return { + valid: missingByStep.length === 0, + missingByStep, + } +} From d53bb5cbfbfacba94f0d8c58dff0e046981e75c7 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Thu, 16 Apr 2026 17:11:28 -0700 Subject: [PATCH 10/23] docs: align guides with current app (ports, routes, TopNav, Jest) - README/PROJECT_OVERVIEW: port 3000, real npm scripts, structure - EVENT_FORM/DEVELOPMENT_WORKFLOW/DEV_TOKEN: URLs and eventType route - FRONTEND: S2, App routes table, TopNav-based new-route steps - TESTING: Jest layout, test:unit, CI as lint+type-check, no e2e script - TOP_NAV_LAYOUT/USER_PANEL: paths, nav items, remove SideBar confusion - MODULAR_COMPONENT_PATTERN/EVENT_FORM: pages/EventForm paths Made-with: Cursor --- docs/DEVELOPMENT_WORKFLOW.md | 8 +- docs/DEV_TOKEN_GUIDE.md | 2 +- docs/EVENT_FORM.md | 18 +-- docs/FRONTEND.md | 229 ++++-------------------------- docs/MODULAR_COMPONENT_PATTERN.md | 31 ++-- docs/PROJECT_OVERVIEW.md | 46 +++--- docs/README.md | 53 +++---- docs/TESTING.md | 94 +++++------- docs/TOP_NAV_LAYOUT.md | 46 +++--- docs/USER_PANEL_IMPLEMENTATION.md | 38 +++-- 10 files changed, 167 insertions(+), 398 deletions(-) diff --git a/docs/DEVELOPMENT_WORKFLOW.md b/docs/DEVELOPMENT_WORKFLOW.md index b6579b9..c65ff7e 100644 --- a/docs/DEVELOPMENT_WORKFLOW.md +++ b/docs/DEVELOPMENT_WORKFLOW.md @@ -85,7 +85,7 @@ aio app run --local ``` **What happens:** -- ✅ Frontend at `localhost:9080` +- ✅ Frontend at `localhost:3000` (see `package.json` `dev` script) - ✅ Actions run in local OpenWhisk container - ✅ Need to access via ExC Shell for IMS @@ -93,7 +93,7 @@ aio app run --local **Access locally running app:** ``` -https://experience.adobe.com/?devMode=true&localDevUrl=https://localhost:9080#/@org/app-id +https://experience.adobe.com/?devMode=true&localDevUrl=https://localhost:3000#/@org/app-id ``` --- @@ -107,7 +107,7 @@ aio app run ``` **What happens:** -- ✅ Frontend at `localhost:9080` +- ✅ Frontend at `localhost:3000` (see `package.json` `dev` script) - ✅ Calls deployed backend actions - ❌ No IMS authentication in standalone mode @@ -311,7 +311,7 @@ touch web-src/src/components/NewFeature.tsx aio app run --local # Access via ExC Shell -# https://experience.adobe.com/?devMode=true&localDevUrl=https://localhost:9080 +# https://experience.adobe.com/?devMode=true&localDevUrl=https://localhost:3000 ``` --- diff --git a/docs/DEV_TOKEN_GUIDE.md b/docs/DEV_TOKEN_GUIDE.md index 679e849..0243db9 100644 --- a/docs/DEV_TOKEN_GUIDE.md +++ b/docs/DEV_TOKEN_GUIDE.md @@ -167,7 +167,7 @@ const apiKey = env.API_KEY The system supports a non-invasive test mode that prevents write operations: ``` -http://localhost:9080?nonInvasiveTest=true +http://localhost:3000?nonInvasiveTest=true ``` When enabled: diff --git a/docs/EVENT_FORM.md b/docs/EVENT_FORM.md index 1f49bd0..1dda030 100644 --- a/docs/EVENT_FORM.md +++ b/docs/EVENT_FORM.md @@ -38,7 +38,7 @@ The Event Form is a comprehensive, production-ready multi-step wizard for creati | Total Lines of Code | ~2500 (distributed across components) | | Form Steps | 4 | | Form Fields | 50+ | -| Modular Components | 13 dedicated EventForm components | +| Modular Components | Many step/feature modules under `pages/EventForm/` | | Shared Components | 10+ reusable components | | Linter Errors | 0 | | Type Coverage | 100% | @@ -47,17 +47,17 @@ The Event Form is a comprehensive, production-ready multi-step wizard for creati ### Create New Event ```bash -# Navigate in browser -http://localhost:9080/#/events/new +# Navigate in browser (eventType: e.g. InPerson, Webinar — see event routes in App.tsx) +http://localhost:3000/#/events/new/InPerson # Or programmatically -navigate('/events/new') +navigate('/events/new/InPerson') ``` ### Edit Existing Event ```bash # Navigate in browser -http://localhost:9080/#/events/edit/EVENT_ID +http://localhost:3000/#/events/edit/EVENT_ID # Or programmatically navigate(`/events/edit/${eventId}`) @@ -69,7 +69,7 @@ navigate(`/events/edit/${eventId}`) npm run dev # Navigate to -http://localhost:9080/#/events/new +http://localhost:3000/#/events/new/InPerson # Test workflow: 1. Select cloud type and series @@ -276,7 +276,7 @@ EventForm (Main Container) ``` web-src/src/components/ ├── EventForm.tsx # Main form container & wizard logic -└── EventForm/ # 13 Modular components +└── EventForm/ # Modular step components ├── index.ts # Barrel exports ├── EventFormatComponent.tsx # Cloud + Series selection ├── EventInfoComponent.tsx # Title, dates, timezone, description @@ -538,7 +538,7 @@ const step4IsValid = true ### Manual Testing Checklist #### Create Flow -- [ ] Navigate to `/events/new` +- [ ] Navigate to `/events/new/:eventType` (e.g. `/events/new/InPerson`) - [ ] Verify all dropdowns populated - [ ] Select cloud type (watch series filter) - [ ] Fill required fields only @@ -636,7 +636,7 @@ describe('EventFormatComponent', () => { All planned modular components have been implemented: ``` -web-src/src/components/EventForm/ +web-src/src/pages/EventForm/ ├── EventFormatComponent.tsx # ✅ Cloud + Series selection ├── EventInfoComponent.tsx # ✅ Title, dates, description ├── EventTagsComponent.tsx # ✅ Tags selection diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index adfe623..ff80ef7 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -2,210 +2,46 @@ ## Architecture Overview -The frontend is a **React + TypeScript** application built with **Adobe React Spectrum** components, organized with clear separation of concerns and reusable patterns. +The frontend is a **React + TypeScript** SPA using **React Spectrum 2** (`@react-spectrum/s2`). Route-level features live under `pages/`; reusable UI lives under `components/shared/`; the shell uses `components/layout/TopNav.tsx`. ## Directory Structure ``` web-src/src/ -├── components/ # React components -│ ├── shared/ # Reusable UI components -│ │ ├── DataTable.tsx # Generic table with actions -│ │ ├── FormWizard.tsx # Multi-step form container -│ │ ├── FormCard.tsx # Styled card for form sections -│ │ ├── StatusBadge.tsx # Status indicators -│ │ ├── LoadingSpinner.tsx # Loading states -│ │ ├── RichTextEditor.tsx # Rich text input -│ │ ├── ImageUploader.tsx # Image upload with drag & drop -│ │ ├── TagSelector.tsx # Tag/category picker -│ │ ├── HeadingWithTooltip.tsx # Heading with info tooltip -│ │ ├── AutocompleteTextField.tsx # Autocomplete input -│ │ └── ResourceDashboardLayout.tsx # Dashboard layout -│ ├── EventForm/ # Modular event form components (13 components) -│ │ ├── EventFormatComponent.tsx # Cloud + Series selection -│ │ ├── EventInfoComponent.tsx # Title, dates, description -│ │ ├── EventTagsComponent.tsx # Tags and categories -│ │ ├── VenueComponent.tsx # Venue with Google Places -│ │ ├── SpeakersComponent.tsx # Speaker management -│ │ ├── SponsorsComponent.tsx # Sponsor management -│ │ ├── AgendaComponent.tsx # Agenda items -│ │ ├── EventImagesComponent.tsx # Image management -│ │ ├── ProfilesComponent.tsx # Speaker/host profiles -│ │ ├── RegistrationConfigComponent.tsx # Registration settings -│ │ ├── RegistrationFieldsComponent.tsx # RSVP form fields -│ │ ├── PageMetadataComponent.tsx # SEO metadata -│ │ └── index.ts # Barrel exports -│ ├── App.tsx # Main app component & routing -│ ├── TopNav.tsx # Top navigation bar -│ ├── Home.tsx # Home page -│ ├── EventForm.tsx # Event form wizard (main container) -│ ├── EventsDashboard.tsx # Events list dashboard -│ ├── SeriesDashboard.tsx # Series list dashboard -│ ├── SeriesForm.tsx # Series create/edit -│ ├── OrgTeamManagement.tsx # Org & team CRUD -│ ├── RegistrationDashboard.tsx # Registration management -│ ├── UserProfile.tsx # IMS user profile -│ ├── UserPanel.tsx # User panel dropdown -│ ├── DevTokenButton.tsx # Dev token status button -│ └── DevTokenDialog.tsx # Dev token input dialog -├── services/ -│ ├── api.ts # Centralized API service (ESP/ESL) -│ ├── tokenStorage.ts # Dev token storage -│ ├── requestHelpers.ts # HTTP request utilities -│ ├── payloadBuilders.ts # API payload construction -│ ├── dataEnrichment.ts # Data transformation utilities -│ └── eventEnrichment.ts # Event data enrichment -├── types/ -│ ├── domain.ts # Domain type definitions -│ └── google-places.d.ts # Google Places API types -├── contexts/ -│ ├── ApiContext.tsx # API context provider -│ └── EventFormContext.tsx # Event form state context +├── components/ # App.tsx, layout (TopNav), shared/, user/, dev/, … +├── pages/ # Route-level features (dashboards, EventForm, admin) +│ └── EventForm/ # Event wizard + modular step components +├── contexts/ # Api, Auth, Toast, EventForm, RBAC, Group, … ├── hooks/ -│ ├── useLoadData.ts # Data loading hook -│ ├── useDevToken.ts # Dev token management hook -│ ├── useEventFormComponent.ts # Event form component hook -│ ├── useEventFormSave.ts # Event form save logic -│ └── useEventTypeFeatures.ts # Event type feature flags +├── services/ # api.ts, caching, payload builders, enrichment ├── config/ -│ ├── constants.ts # API hosts, supported clouds -│ ├── env.ts # Environment configuration -│ └── eventTypeConfig.ts # Event type configurations -├── mocks/ -│ ├── list-series.ts # Mock series data -│ ├── list-events.ts # Mock events data -│ └── index.ts # Mock exports +├── types/ ├── utils/ -│ ├── formPersistence.ts # Form auto-save utilities -│ ├── loadGooglePlaces.ts # Google Places API loader -│ ├── socialPlatformDetector.ts # Social link detection -│ └── dataFilters.ts # Data filtering utilities -├── styles/ -│ └── designSystem.ts # Design system tokens -├── index.tsx # Application entry point -├── index.css # Global styles -└── types.ts # IMS & runtime types +├── styles/ # designSystem.ts +├── index.tsx +└── index.css ``` -## Core Components +Canonical **routes** are defined in `components/App.tsx` (copy/paste from source when in doubt). Summary: -### Application Shell (`App.tsx`) +| Path | Purpose | +|------|---------| +| `/` | Home | +| `/overview` | Overview dashboard | +| `/profile` | User profile (IMS) | +| `/series`, `/series/new`, `/series/edit/:id` | Series list and form | +| `/events`, `/events/new/:eventType`, `/events/edit/:id` | Events list and event wizard | +| `/registrations`, `/registrations/:eventId` | Registrations | +| `/speakers` | Speakers | +| `/users` | User management | +| `/access` | Scope group management | +| `/roles` | Role management | +| `/configs` | Config management | +| `/about` | About | -Main component that sets up: -- Adobe React Spectrum Provider with light theme -- React Router (HashRouter for ExC Shell compatibility) -- Grid layout with sidebar and content area -- Error boundary for graceful error handling -- Runtime event listeners (configuration, history) +**Top navigation:** `components/layout/TopNav.tsx` — horizontal `NavLink`s (items may be hidden based on RBAC and group selection). **User menu:** `components/user/UserPanel.tsx`. -**Routes:** -```typescript -/ → Home page -/profile → User profile (IMS) -/organizations → Org & team management -/resources → Resources dashboard -/series/new → Create series -/series/edit/:id → Edit series -/events/new → Create event (wizard) -/events/edit/:id → Edit event (wizard) -/registrations → Registration dashboard -/registrations/:eventId → Event-specific registrations -/actions → Backend action tester -/about → About page -``` - -### Navigation (`SideBar.tsx`) - -Vertical navigation menu with: -- NavLink components for route highlighting -- Organized sections (Management, Resources, System) -- Adobe Spectrum styling for consistency - -### User Profile (`UserProfile.tsx`) - -Displays IMS (Identity Management System) profile: -- User ID, name, email -- Organization ID -- Masked authentication token -- Additional profile fields - -### Organizations & Teams (`OrgTeamManagement.tsx`) - -Full CRUD interface with: -- **Tabbed layout**: Organizations | Teams -- **DataTable display** with inline actions -- **Modal dialogs** for create/edit -- **Confirmation dialogs** for deletion -- **Organization-scoped team management** - -**Key Patterns:** -- Separate state for organizations and teams -- Inline editing with form modals -- Delete confirmations to prevent accidents -- Real-time updates after CRUD operations - -### Resources Dashboard (`ResourcesDashboard.tsx`) - -Central hub for viewing all resources: -- **Tabbed interface**: Series | Events | Sessions -- **Count badges** on tabs -- **Status indicators** for each resource -- **Quick actions**: View, edit, delete -- **Navigation** to create/edit forms - -**Use Case:** Get a bird's-eye view of all resources in the system. - -### Series Form (`SeriesForm.tsx`) - -**Single-step form** for series: -- Name, description -- Organization selector -- Date range picker (start/end dates) -- Status dropdown (draft, active, completed, archived) -- Form validation before submission -- Success/error feedback - -**Key Features:** -- Pre-fills data when editing (via route param `:id`) -- Uses `@internationalized/date` for date handling -- Validates date ranges (end must be after start) - -### Event Form (`EventForm.tsx`) - -**Multi-step wizard** for events: - -**Step 1 - Basic Info:** -- Name, description -- Series selection -- Organization selection - -**Step 2 - Date & Location:** -- Start/end date-time -- Location - -**Step 3 - Capacity & Registration:** -- Capacity (max attendees) -- Registration open/closed toggle -- Event status - -**Key Features:** -- Progress bar showing current step -- Step validation (can't proceed if invalid) -- Back/Next navigation -- Pre-fills data when editing -- Uses FormWizard shared component - -### Registration Dashboard (`RegistrationDashboard.tsx`) - -Event registration management: -- **Event selector**: Dropdown to choose event -- **Statistics cards**: Total, confirmed, pending, attended, cancelled -- **Registration table**: All attendees with details -- **Status updates**: Click to cycle through statuses -- **CSV export**: Download registrations -- **Delete**: Remove registrations with confirmation - -**Use Case:** Manage attendees for events, track attendance, export data. +For the event wizard, see [EVENT_FORM.md](./EVENT_FORM.md). ## Shared Components @@ -648,15 +484,12 @@ navigate('/resources') ### Adding a New Route -1. **Create component** in `components/` -2. **Add route** in `App.tsx`: - ```typescript - } /> - ``` -3. **Add navigation** in `SideBar.tsx`: +1. **Create a page component** under `pages/` (and export from `pages/index.ts` if using the barrel). +2. **Add a route** in `components/App.tsx`: ```typescript - New Page + } /> ``` +3. **Add a nav link** in `components/layout/TopNav.tsx` (respect RBAC / `useGroup` patterns used by existing links). ### Adding a New Entity diff --git a/docs/MODULAR_COMPONENT_PATTERN.md b/docs/MODULAR_COMPONENT_PATTERN.md index 44cf5da..810dcc0 100644 --- a/docs/MODULAR_COMPONENT_PATTERN.md +++ b/docs/MODULAR_COMPONENT_PATTERN.md @@ -42,22 +42,13 @@ Instead of having all form logic inline within a single large component, we extr ### Directory Layout ``` -web-src/src/components/ +web-src/src/pages/EventForm/ ├── EventForm.tsx # Main form container & wizard logic -└── EventForm/ # 13 Modular components - ├── index.ts # Barrel export file - ├── EventFormatComponent.tsx # Cloud + Series selection - ├── EventInfoComponent.tsx # Title, dates, description - ├── EventTagsComponent.tsx # Tags and categories - ├── VenueComponent.tsx # Venue with Google Places - ├── SpeakersComponent.tsx # Speaker management - ├── SponsorsComponent.tsx # Sponsor management - ├── AgendaComponent.tsx # Agenda items with repeater - ├── EventImagesComponent.tsx # Event images - ├── ProfilesComponent.tsx # Speaker/host profiles - ├── RegistrationConfigComponent.tsx # Registration settings - ├── RegistrationFieldsComponent.tsx # RSVP form fields - └── PageMetadataComponent.tsx # SEO metadata +├── index.ts # Barrel exports (as applicable) +├── EventFormatComponent.tsx # Cloud + Series selection +├── EventInfoComponent.tsx # Title, dates, description +├── … # Additional step/feature modules +└── SessionManagement/ # Sessions sub-area (if present) ``` ### Component Template @@ -177,7 +168,7 @@ import { EventFormatComponent, EventInfoComponent } from './EventForm' ### Example 1: EventFormatComponent **Purpose:** Cloud type and series selection -**Location:** `web-src/src/components/EventForm/EventFormatComponent.tsx` +**Location:** `web-src/src/pages/EventForm/EventFormatComponent.tsx` **Key Features:** - Fetches clouds and series lists from API @@ -207,7 +198,7 @@ interface EventFormatComponentProps { ### Example 2: EventInfoComponent **Purpose:** Event information, dates, and secondary links -**Location:** `web-src/src/components/EventForm/EventInfoComponent.tsx` +**Location:** `web-src/src/pages/EventForm/EventInfoComponent.tsx` **Key Features:** - Manages event title with URL title sync logic @@ -252,7 +243,7 @@ interface EventInfoComponentProps { ### Example 3: AgendaComponent **Purpose:** Repeatable agenda items with ordering and constraints -**Location:** `web-src/src/components/EventForm/AgendaComponent.tsx` +**Location:** `web-src/src/pages/EventForm/AgendaComponent.tsx` **Key Features:** - Repeatable fieldsets for agenda items @@ -288,7 +279,7 @@ interface AgendaComponentProps { ### Example 4: VenueComponent **Purpose:** Venue information with Google Places integration and image upload -**Location:** `web-src/src/components/EventForm/VenueComponent.tsx` +**Location:** `web-src/src/pages/EventForm/VenueComponent.tsx` **Key Features:** - Google Places API autocomplete for venue name @@ -414,7 +405,7 @@ return ### Step 2: Create Component File ```bash # Create new file in EventForm folder -touch web-src/src/components/EventForm/MyComponent.tsx +touch web-src/src/pages/EventForm/MyComponent.tsx ``` ### Step 3: Define Props Interface diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index c236fbf..06c27bd 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -7,10 +7,9 @@ ### Local Development ```bash npm install -aio app run # Start dev server (localhost:9080) -aio app run --local # Run actions locally -aio app test # Run unit tests -aio app test --e2e # Run e2e tests +npm run dev # Start dev server (http://localhost:3000) +npm run dev:local # UI + actions local (port 3000) +aio app test # Adobe CLI tests (when configured) ``` ### Deployment @@ -25,19 +24,16 @@ aio app undeploy # Remove deployment EMC/ ├── web-src/ # Frontend React app │ └── src/ -│ ├── components/ # UI components -│ │ ├── shared/ # Reusable shared components -│ │ └── EventForm/ # Modular event form components +│ ├── components/ # App shell, layout, shared UI +│ ├── pages/ # Route-level pages (EventForm, dashboards, admin) │ ├── services/ # API service layer (ESP/ESL external APIs) │ ├── types/ # TypeScript definitions │ ├── hooks/ # Custom React hooks │ ├── contexts/ # React context providers │ ├── config/ # Configuration and constants -│ ├── mocks/ # Mock data for development │ └── utils/ # Utility functions -├── actions/ # App Builder actions (unused, boilerplate only) -├── test/ # Unit tests -├── e2e/ # End-to-end tests +├── actions/ # App Builder actions (I/O Runtime) +├── jest.config.js # Jest (tests: web-src/src/**/*.test.ts) └── docs/ # Documentation ├── PROJECT_OVERVIEW.md # This file ├── DEVELOPMENT_WORKFLOW.md # Development workflow @@ -75,7 +71,7 @@ Organization (IMS Org) ### Frontend - **React 18** with **TypeScript** -- **Adobe React Spectrum 2** - UI component library +- **React Spectrum 2** (`@react-spectrum/s2`) — UI components - **React Router** - Client-side routing - **@internationalized/date** - Date handling @@ -103,11 +99,10 @@ Organization (IMS Org) ### User Interface - ✅ User profile display (IMS integration) -- ✅ Organization & team CRUD operations -- ✅ Series management with status tracking -- ✅ Multi-step event creation wizard -- ✅ Registration dashboard with CSV export -- ✅ Resource dashboard (view all series/events/sessions) +- ✅ Series and event management +- ✅ Multi-step event creation and editing wizard +- ✅ Registrations (attendees) and related dashboards +- ✅ Overview, speakers, configs, and admin routes (see `App.tsx`) ### Shared Components - **DataTable** - Reusable table with actions @@ -160,11 +155,11 @@ npm install # Install dependencies aio app run # Start dev server aio app run --local # Local serverless -# Testing -npm test # Unit tests -npm run e2e # E2E tests -npm run lint # Check code style +# Quality +npm run lint # ESLint npm run type-check # TypeScript validation +npm run test:unit # Jest (when tests exist) +npm run check # lint + type-check # Deployment aio app deploy # Deploy everything @@ -191,11 +186,10 @@ aio rt activation get # Get activation details ## Important Notes -- Frontend runs on `localhost:9080` by default -- Actions are deployed to Adobe I/O Runtime (even in dev mode) -- Use `--local` flag to run actions locally -- TypeScript is used only in frontend (`web-src/`) -- Backend actions use JavaScript (Node.js) +- Local UI dev server uses **port 3000** (`npm run dev` / `npm run dev:local`) +- Actions are deployed to Adobe I/O Runtime (unless you use `--local`) +- TypeScript is used in the frontend (`web-src/`) +- Backend actions may use JavaScript (Node.js) ## Next Steps diff --git a/docs/README.md b/docs/README.md index 95be514..7671ca3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ Welcome to the Event Management Console (EMC) documentation! This index will hel - Adobe Spectrum UI components - **[Event Form Guide](./EVENT_FORM.md)** - Complete event form implementation - - Multi-step wizard with 4 main steps + - Multi-step wizard (`pages/EventForm/`) - Modular component architecture - Validation and data flow - Create and edit modes @@ -44,6 +44,8 @@ Welcome to the Event Management Console (EMC) documentation! This index will hel - Mock support - Consistent error handling +- **[Cache Implementation](./CACHE_IMPLEMENTATION.md)** - GET caching, deduplication, invalidation + - **[Google Places API Setup](./GOOGLE_PLACES_SETUP.md)** - Google Places integration - API key setup and security - Venue autocomplete configuration @@ -51,11 +53,10 @@ Welcome to the Event Management Console (EMC) documentation! This index will hel - Troubleshooting guide ### Testing -- **[Testing Guide](./TESTING.md)** - Unit and E2E testing patterns - - Jest configuration - - Testing components - - Testing actions - - E2E with Puppeteer +- **[Testing Guide](./TESTING.md)** - Jest setup and patterns (`npm run test:unit` when tests exist) + +### Access control +- **[RBAC permission gating](./RBAC_PERMISSION_GATING_IMPLEMENTATION.md)** - Resource checks and UI gates ## 🔐 Local Development Features @@ -184,14 +185,13 @@ ENVIRONMENT=dev # or 'stage' to test against stage APIs ```bash # Development npm run dev # Start local dev server (port 3000) -npm run dev:local # Run with local actions -aio app run # Alternative (port 9080) +npm run dev:local # Run with local actions (port 3000) -# Testing -npm test # Run unit tests -npm run e2e # Run E2E tests -npm run lint # Check code style +# Quality +npm run lint # ESLint (Node/actions src; web-src excluded) npm run type-check # TypeScript validation +npm run test:unit # Jest (when `*.test.ts` files exist under web-src/src) +npm run check # lint + type-check # Deployment aio app deploy # Deploy to your workspace (dev) @@ -223,33 +223,20 @@ EMC/ ├── docs/ # 📚 This documentation │ ├── README.md # This index file │ ├── PROJECT_OVERVIEW.md # Start here! -│ ├── DEVELOPMENT_WORKFLOW.md # How to develop -│ ├── FRONTEND.md # Frontend guide -│ ├── EVENT_FORM.md # Event form guide -│ ├── MODULAR_COMPONENT_PATTERN.md # Component patterns -│ ├── API_CENTRALIZATION.md # API architecture -│ ├── TESTING.md # Testing guide -│ ├── DEV_TOKEN_QUICKSTART.md # ⚡ Quick setup -│ └── DEV_TOKEN_GUIDE.md # 📖 Complete guide +│ └── … # See sections above for full list │ ├── web-src/ # Frontend application │ └── src/ -│ ├── components/ # React components -│ ├── pages/ # Page components +│ ├── components/ # App shell, layout, shared UI +│ ├── pages/ # Route-level pages (EventForm, dashboards, …) │ ├── services/ # API services -│ │ ├── api.ts # Main API service -│ │ ├── tokenStorage.ts # Token management -│ │ └── *Enrichment.ts # Data enrichment utilities │ ├── hooks/ # React hooks │ ├── contexts/ # React context providers │ ├── config/ # Configuration -│ │ ├── constants.ts # Environment & API config -│ │ └── env.ts # Environment variables │ └── types/ # TypeScript definitions │ ├── actions/ # Backend actions (I/O Runtime) -├── test/ # Unit tests -├── e2e/ # E2E tests +├── jest.config.js # Jest (tests: web-src/src/**/*.test.ts) └── app.config.yaml # App configuration ``` @@ -303,8 +290,8 @@ EMC/ ### Event Form Implementation (November 2025) - ✨ **Production-Ready Multi-Step Form** - Complete event creation/editing - - 4-step wizard matching v1 reference structure - - Modular component architecture (EventFormatComponent, EventInfoComponent) + - Multi-step wizard under `web-src/src/pages/EventForm/` + - Modular components (e.g. EventFormatComponent, EventInfoComponent) - Full TypeScript type safety - Comprehensive validation - See [Event Form Guide](./EVENT_FORM.md) @@ -355,8 +342,8 @@ When updating documentation: --- -**Last Updated:** January 26, 2026 -**Version:** 1.6.0 +**Last Updated:** April 16, 2026 +**Version:** 1.7.0 Happy coding! 🚀 diff --git a/docs/TESTING.md b/docs/TESTING.md index c42b58c..7f6cc6e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -2,39 +2,39 @@ ## Overview -The EMC project uses **Jest** for both unit and end-to-end testing. Tests cover both frontend components and backend actions. +**Jest** is configured at the repo root (`jest.config.js`, preset `ts-jest`). Tests are discovered as `web-src/src/**/*.test.ts`. There are **no committed test files yet**; add tests alongside source as needed. -## Test Structure +**CI (`.github/workflows/pr_test.yml`):** pull requests run `npm run lint` and `npm run type-check` only — not Jest. + +End-to-end automation is **not** wired in `package.json`; the E2E section below is optional reference material. + +## Test layout (expected) ``` EMC/ -├── test/ # Unit tests for backend actions -│ ├── sample.test.js -│ ├── sampleMessage.test.js -│ └── utils.test.js -├── e2e/ # End-to-end tests -│ ├── sample.e2e.test.js -│ └── sampleMessage.e2e.test.js -└── jest.setup.js # Jest configuration +├── jest.config.js # Jest entry +├── jest.setup.js # Shared setup (timeouts, etc.) +└── web-src/src/ + └── **/*.test.ts # Colocated tests (when added) ``` ## Running Tests ```bash -# Run all unit tests -npm test +# Run all Jest tests (once files exist) +npm run test:unit -# Run specific test file -npm test -- sample.test.js +# Run a specific file +npm run test:unit -- path/to/file.test.ts -# Run with coverage -npm test -- --coverage +# With coverage +npm run test:unit -- --coverage -# Run e2e tests -npm run e2e +# Watch mode +npm run test:unit -- --watch -# Run tests in watch mode -npm test -- --watch +# Adobe CLI (separate from Jest — use when project enables it) +aio app test ``` ## Backend Action Testing @@ -489,14 +489,15 @@ describe('Organizations E2E', () => { ### Running E2E Tests +There is **no** `npm run e2e` script in this repo today. If you add Puppeteer/Playwright or `aio app test --e2e`, document the exact command here. Example placeholder: + ```bash -# Set environment variables +# Set environment variables (example) export E2E_BASE_URL=https://your-namespace.adobeioruntime.net/api/v1/web/EMC export E2E_TOKEN=your-test-token export E2E_ORG=your-test-org -# Run e2e tests -npm run e2e +# Then run your chosen E2E runner (not configured by default) ``` ## Test Coverage @@ -505,7 +506,7 @@ npm run e2e ```bash # Generate coverage report -npm test -- --coverage +npm run test:unit -- --coverage # View coverage in browser open coverage/lcov-report/index.html @@ -631,42 +632,15 @@ test('test 2', () => { use sharedData }) // Depends on test 1 ## Continuous Integration -### GitHub Actions Example +### GitHub Actions (current repo) -```yaml -# .github/workflows/test.yml -name: Tests +Pull requests use `.github/workflows/pr_test.yml`: **lint** and **type-check**. Add a `npm run test:unit` step when the suite exists and should gate merges. -on: [push, pull_request] +### Example — optional Jest step -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Setup Node.js - uses: actions/setup-node@v2 - with: - node-version: '22' - - - name: Install dependencies - run: npm ci - - - name: Run linter - run: npm run lint - - - name: Run type check - run: npm run type-check - +```yaml - name: Run unit tests - run: npm test -- --coverage - - - name: Upload coverage - uses: codecov/codecov-action@v2 - with: - files: ./coverage/lcov.info + run: npm run test:unit -- --coverage ``` ## Debugging Tests @@ -675,10 +649,10 @@ jobs: ```bash # Run specific test file -npm test -- myaction.test.js +npm run test:unit -- path/to/module.test.ts # Run specific test case -npm test -- -t "creates organization" +npm run test:unit -- -t "creates organization" ``` ### Using Debugger @@ -693,13 +667,13 @@ test('debuggable test', async () => { **Run with debugger:** ```bash -node --inspect-brk node_modules/.bin/jest --runInBand myaction.test.js +node --inspect-brk node_modules/.bin/jest --runInBand path/to/module.test.ts ``` ### Verbose Output ```bash -npm test -- --verbose +npm run test:unit -- --verbose ``` ## Common Testing Pitfalls diff --git a/docs/TOP_NAV_LAYOUT.md b/docs/TOP_NAV_LAYOUT.md index 68dc4b9..41eabaa 100644 --- a/docs/TOP_NAV_LAYOUT.md +++ b/docs/TOP_NAV_LAYOUT.md @@ -8,7 +8,7 @@ The application has been redesigned from a **sidebar layout** to a modern **top ``` ┌────────────────────────────────────────────────────────────────┐ -│ EMC Home Organizations Resources Registrations [JD] ▼ │ ← Top Nav +│ EMC Home Overview Events … Registrations Speakers [JD]▼ │ ← Top Nav ├────────────────────────────────────────────────────────────────┤ │ │ │ Main Content Area │ @@ -24,24 +24,22 @@ The application has been redesigned from a **sidebar layout** to a modern **top ``` ┌──────────────────────────────────────────────────────────────────────┐ -│ │ -│ [EMC] Home Organizations Resources Registrations Actions │ -│ ──── About │ -│ Selected [JD] John Doe ▼ │ -│ │ +│ [EMC] Home Overview Events Registrations Speakers Series … │ +│ ──── │ +│ Selected About [JD] ▼ │ └──────────────────────────────────────────────────────────────────────┘ ``` **Components:** - **Left**: Brand logo "EMC" -- **Center**: Horizontal navigation links +- **Center**: Horizontal navigation links (subset may be hidden until a group is selected or per RBAC) - **Right**: Compact user panel with dropdown ## Key Features ### 1. Top Navigation (TopNav Component) -**Location:** `web-src/src/components/SideBar.tsx` (renamed functionality to TopNav) +**Location:** `web-src/src/components/layout/TopNav.tsx` **Features:** - ✅ Horizontal navigation layout @@ -50,12 +48,10 @@ The application has been redesigned from a **sidebar layout** to a modern **top - ✅ Responsive spacing - ✅ Shadow for depth -**Navigation Items:** -- Home -- Organizations -- Resources -- Registrations -- Actions +**Navigation items (typical; see source for permission-gated links):** +- Home, Overview +- Events, Registrations, Speakers (when permitted) +- Series, Configs (when permitted) - About ### 2. Compact User Panel @@ -148,12 +144,11 @@ The application has been redesigned from a **sidebar layout** to a modern **top ## Component Changes -### 1. SideBar.tsx → TopNav -- Changed from vertical to horizontal layout -- Renamed component to `TopNav` -- Integrated `UserPanel` component +### 1. Legacy sidebar → `TopNav` +- Replaced vertical sidebar with horizontal `components/layout/TopNav.tsx` +- Integrated `UserPanel` on the right - Added brand logo section -- Removed "User Profile" link (redundant with profile widget) +- Profile access via user panel / `/profile` (not a duplicate nav item) ### 2. UserPanel.tsx - Added `compact` prop for layout modes @@ -253,7 +248,8 @@ Possible additions: ### Manual Testing ```bash -aio app run +npm run dev +# UI: http://localhost:3000 ``` **Check:** @@ -290,19 +286,19 @@ aio app run | File | Purpose | |------|---------| -| `SideBar.tsx` | TopNav component (renamed functionality) | -| `UserPanel.tsx` | Profile widget with compact mode | -| `App.tsx` | Layout grid configuration | +| `components/layout/TopNav.tsx` | Primary navigation bar | +| `components/user/UserPanel.tsx` | Profile widget (compact in top nav) | +| `components/App.tsx` | Layout grid and routes | | `index.css` | Top nav and link styling | ## Summary -The application now features a **modern top navigation bar** with: +The application uses a **top navigation bar** with: - Horizontal navigation links - Compact user profile widget on the right - Full-width content area - Clean, professional appearance - Better space utilization -This layout is more suitable for desktop applications and provides a better user experience! 🚀 +This layout fits desktop-style admin apps and keeps primary actions easy to scan. diff --git a/docs/USER_PANEL_IMPLEMENTATION.md b/docs/USER_PANEL_IMPLEMENTATION.md index 11c6017..951d14f 100644 --- a/docs/USER_PANEL_IMPLEMENTATION.md +++ b/docs/USER_PANEL_IMPLEMENTATION.md @@ -2,13 +2,13 @@ ## Overview -A persistent **UserPanel** component has been added to display the current IMS (Identity Management System) user information in the application sidebar. This provides users with constant visibility of their authentication status and profile details. +A persistent **UserPanel** component shows the current IMS (Identity Management System) user in the **top navigation** (right side), so authentication status and profile actions stay visible without a sidebar. ## Implementation Details ### Component: `UserPanel.tsx` -**Location:** `web-src/src/components/UserPanel.tsx` +**Location:** `web-src/src/components/user/UserPanel.tsx` **Features:** - ✅ Displays IMS user information (name, email, user ID) @@ -21,18 +21,11 @@ A persistent **UserPanel** component has been added to display the current IMS ( ### Integration Points -#### 1. App Component (`App.tsx`) -```typescript - - {/* New: IMS-connected user panel */} - - -``` +#### 1. Top navigation (`TopNav.tsx`) -#### 2. SideBar Component (`SideBar.tsx`) -Updated to accept `ims` prop (for future enhancements). +`TopNav` renders the Adobe logo, primary `NavLink`s, dev token UI (localhost), and **`UserPanel ims={ims} compact`** on the right when the user is signed in (standalone mode shows **Sign In** until authenticated). -#### 3. Styling (`index.css`) +#### 2. Styling (`index.css`) Added custom styles for hover effects and borders. ## User Experience @@ -133,12 +126,12 @@ export const MyComponent: React.FC = ({ ims }) => { 1. **Start the application:** ```bash - aio app run + npm run dev ``` 2. **Check UserPanel appears:** - - Look at the top of the sidebar - - Should show your IMS user name and email + - Look at the **top right** of the window (inside `TopNav`) + - Should show your IMS user name (compact mode may hide email in the bar; open the menu to verify) 3. **Test interactions:** - Click on the user panel @@ -152,9 +145,9 @@ export const MyComponent: React.FC = ({ ims }) => { ### Unit Test Template ```typescript -// __tests__/UserPanel.test.tsx +// Example: colocate as web-src/src/components/user/UserPanel.test.tsx import { render, screen, fireEvent } from '@testing-library/react' -import { UserPanel } from '../UserPanel' +import { UserPanel } from './UserPanel' import { BrowserRouter } from 'react-router-dom' const mockIms = { @@ -223,10 +216,11 @@ const handleLogout = () => { ``` web-src/src/components/ -├── UserPanel.tsx # New: IMS-connected user panel -├── UserProfile.tsx # Existing: Full profile page -├── SideBar.tsx # Updated: Now receives ims prop -└── App.tsx # Updated: Passes ims to UserPanel +├── user/ +│ └── UserPanel.tsx # IMS user menu (compact in TopNav) +├── layout/ +│ └── TopNav.tsx # Renders UserPanel on the right +└── App.tsx # Grid shell; TopNav in header area ``` ## Accessibility @@ -278,7 +272,7 @@ Works in all modern browsers: ## Summary The UserPanel component successfully integrates IMS user information into the application UI, providing: -- **Persistent user context** - Always visible in sidebar +- **Persistent user context** — Visible in the top navigation - **Quick profile access** - One click to full profile - **Organization awareness** - Shows current org context - **Professional appearance** - Follows Adobe design system From bd6e7521cf211c3255258a258b038571e1e2223d Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Thu, 16 Apr 2026 17:50:54 -0700 Subject: [PATCH 11/23] fix(event-form): load language picker from scope locales config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventInfoComponent previously called cachedApi.getLocales(), which hits the ESP GET /v1/locales endpoint. That route is deprecated in events-service-platform in favor of scope-level configs with type "locales" (same LocalesScopeConfig shape as Cloud Management: localeNames + localeUrlCodes). The picker now resolves the active ExC group scope via useGroup().activeGroup.scopeId and loads configs with cachedApi.getConfigsForScope(scopeId, 'locales'). When a locales config exists and localeNames is non-empty, options are built from those entries. If there is no scope id, the request fails, or no locales config is present, we fall back to SUPPORTED_SPEAKER_LOCALES + SPEAKER_LOCALE_LABELS so behavior matches ConfigManagement’s RSVP "available locales" fallback. If the event’s current defaultLocale is not listed in the resolved set (e.g. legacy data), we inject a synthetic picker row so the selected key remains valid until the user changes language. Made-with: Cursor --- .../pages/EventForm/EventInfoComponent.tsx | 69 +++++++++++++------ 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/web-src/src/pages/EventForm/EventInfoComponent.tsx b/web-src/src/pages/EventForm/EventInfoComponent.tsx index 2a788e5..66ecc88 100644 --- a/web-src/src/pages/EventForm/EventInfoComponent.tsx +++ b/web-src/src/pages/EventForm/EventInfoComponent.tsx @@ -2,7 +2,7 @@ * */ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { ComboBox, ComboBoxItem, @@ -28,6 +28,9 @@ import { HeadingWithTooltip, RichTextEditor } from '../../components/shared' import { SPACING } from '../../styles/designSystem' import { cachedApi } from '../../services/api' import { useEventFormComponent } from '../../hooks/useEventFormComponent' +import { useGroup } from '../../contexts/GroupContext' +import type { LocalesScopeConfig } from '../../types/configApi' +import { SUPPORTED_SPEAKER_LOCALES, SPEAKER_LOCALE_LABELS } from '../../config/localeMapping' /** * Safely parse ISO 8601 datetime string for DatePicker @@ -106,16 +109,11 @@ function getMinEndDateTime(startDateTimeStr: string): CalendarDateTime | undefin return addMinutes(startDt, 1) } -const FALLBACK_LOCALE_OPTIONS = [ - { key: 'en-US', label: 'English (US)' }, - { key: 'es-ES', label: 'Spanish' }, - { key: 'fr-FR', label: 'French' }, - { key: 'de-DE', label: 'German' }, - { key: 'ja-JP', label: 'Japanese' }, - { key: 'ko-KR', label: 'Korean' }, - { key: 'pt-BR', label: 'Portuguese (Brazil)' }, - { key: 'zh-CN', label: 'Chinese (Simplified)' }, -] +/** Default picker entries when no scope locales config exists (aligned with ConfigManagement RSVP locales). */ +const DEFAULT_LOCALE_PICKER_OPTIONS = SUPPORTED_SPEAKER_LOCALES.map((key) => ({ + key, + label: SPEAKER_LOCALE_LABELS[key] || key, +})) const TIMEZONE_OPTIONS = getTimeZones().map((tz) => ({ id: tz.name, @@ -130,6 +128,8 @@ const TIMEZONE_OPTIONS = getTimeZones().map((tz) => ({ * startDateTime, endDateTime, timezone, communityForumUrl, secondaryLinkTitle, isPrivate */ export const EventInfoComponent: React.FC = () => { + const { activeGroup } = useGroup() + // ============================================================================ // CONTEXT INTEGRATION // ============================================================================ @@ -174,21 +174,46 @@ export const EventInfoComponent: React.FC = () => { const [hasSecondaryLink, setHasSecondaryLink] = useState(false) const [pendingLocale, setPendingLocale] = useState(null) const [urlValidationError, setUrlValidationError] = useState(null) - const [localeOptions, setLocaleOptions] = useState<{ key: string; label: string }[]>(FALLBACK_LOCALE_OPTIONS) + const [localeOptions, setLocaleOptions] = useState<{ key: string; label: string }[]>(DEFAULT_LOCALE_PICKER_OPTIONS) useEffect(() => { - // getLocales returns `any` — the ESP /v1/locales response shape varies - // (localeNames object, locales array, or bare object) and has no typed interface - cachedApi.getLocales().then((result: Record) => { - const localeMap = result?.localeNames ?? result?.locales ?? result - if (localeMap && typeof localeMap === 'object' && !Array.isArray(localeMap)) { - const options = Object.entries(localeMap as Record).map(([key, label]) => ({ key, label })) - if (options.length > 0) setLocaleOptions(options) + const scopeId = activeGroup?.scopeId + if (!scopeId) { + setLocaleOptions(DEFAULT_LOCALE_PICKER_OPTIONS) + return + } + + let cancelled = false + cachedApi.getConfigsForScope(scopeId, 'locales').then((result) => { + if (cancelled) return + if ('error' in result) { + setLocaleOptions(DEFAULT_LOCALE_PICKER_OPTIONS) + return + } + const localesConfig = result.find((c): c is LocalesScopeConfig => c.type === 'locales') + const names = localesConfig?.localeNames + if (names && typeof names === 'object' && Object.keys(names).length > 0) { + const options = Object.entries(names).map(([key, label]) => ({ key, label })) + setLocaleOptions(options) + } else { + setLocaleOptions(DEFAULT_LOCALE_PICKER_OPTIONS) } }).catch(() => { - // fallback stays in place + if (!cancelled) { + setLocaleOptions(DEFAULT_LOCALE_PICKER_OPTIONS) + } }) - }, []) + + return () => { + cancelled = true + } + }, [activeGroup?.scopeId]) + + const pickerLocaleOptions = useMemo(() => { + if (!locale) return localeOptions + if (localeOptions.some((o) => o.key === locale)) return localeOptions + return [{ key: locale, label: locale }, ...localeOptions] + }, [localeOptions, locale]) useEffect(() => { if (communityForumUrl) { @@ -309,7 +334,7 @@ export const EventInfoComponent: React.FC = () => { selectedKey={locale || null} onSelectionChange={handleLanguageChange} > - {localeOptions.map((opt) => ( + {pickerLocaleOptions.map((opt) => ( {opt.label} ))} From 1aef78d8e89bdf10847e5a8f0d30a6d545912dd0 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Mon, 20 Apr 2026 13:36:13 -0700 Subject: [PATCH 12/23] fix(esp): use customAttributes scope config type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align ConfigType, scope config models, and create bodies with ESP develop (customAttributes instead of custom-attributes). Update Event Form and Config Management fetch/filter/create paths so ?type= and config type match ESP’s strict filter. --- .../src/pages/ConfigManagement/ConfigManagement.tsx | 4 ++-- .../src/pages/EventForm/CustomAttributesComponent.tsx | 6 +++--- web-src/src/types/configApi.ts | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web-src/src/pages/ConfigManagement/ConfigManagement.tsx b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx index ef21691..822dfa9 100644 --- a/web-src/src/pages/ConfigManagement/ConfigManagement.tsx +++ b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx @@ -242,7 +242,7 @@ export const ConfigManagement: React.FC = () => { [configs] ) const customAttrsConfig = useMemo( - () => configs.find((c): c is CustomAttributesScopeConfig => c.type === 'custom-attributes') || null, + () => configs.find((c): c is CustomAttributesScopeConfig => c.type === 'customAttributes') || null, [configs] ) const customAttributes = useMemo( @@ -805,7 +805,7 @@ export const ConfigManagement: React.FC = () => { } } else { const result = await apiService.createConfig(selectedScopeId, { - type: 'custom-attributes', + type: 'customAttributes', attributes: [newAttr], }) if ('error' in result) { diff --git a/web-src/src/pages/EventForm/CustomAttributesComponent.tsx b/web-src/src/pages/EventForm/CustomAttributesComponent.tsx index 3c37c5e..eee2630 100644 --- a/web-src/src/pages/EventForm/CustomAttributesComponent.tsx +++ b/web-src/src/pages/EventForm/CustomAttributesComponent.tsx @@ -138,7 +138,7 @@ const MultiSelectRepeater: React.FC = ({ attr, values, export const CustomAttributesComponent: React.FC = () => { const { formData, updateFormData } = useEventFormComponent({ - componentId: 'custom-attributes', + componentId: 'customAttributes', }) const { activeGroup } = useGroup() @@ -155,9 +155,9 @@ export const CustomAttributesComponent: React.FC = () => { const load = async () => { setLoading(true) try { - const result = await cachedApi.getConfigsForScope(scopeId, 'custom-attributes') + const result = await cachedApi.getConfigsForScope(scopeId, 'customAttributes') if (!('error' in result)) { - const config = result.find(c => c.type === 'custom-attributes') as CustomAttributesScopeConfig | undefined + const config = result.find(c => c.type === 'customAttributes') as CustomAttributesScopeConfig | undefined const enabled = (config?.attributes ?? []).filter(a => a.enabled !== false) setAttributes(enabled) updateFormData({ _customAttributeConfigs: enabled }) diff --git a/web-src/src/types/configApi.ts b/web-src/src/types/configApi.ts index 1465b82..b3e4d9f 100644 --- a/web-src/src/types/configApi.ts +++ b/web-src/src/types/configApi.ts @@ -1,16 +1,16 @@ /** * Scope Configs & Custom Attributes API type definitions * - * Types matching the ESP configs/custom-attributes endpoints. + * Types matching the ESP scope config endpoints. * Configs are scope-inherited (org -> team) and support three types: - * rsvp, locales, and custom-attributes. + * rsvp, locales, and customAttributes. */ // ============================================================================ // Enums & Primitives // ============================================================================ -export type ConfigType = 'rsvp' | 'locales' | 'custom-attributes' +export type ConfigType = 'rsvp' | 'locales' | 'customAttributes' export type RsvpFieldType = 'text' | 'email' | 'phone' | 'select' | 'multi-select' @@ -75,7 +75,7 @@ export interface LocalesScopeConfig extends ScopeConfigBase { } export interface CustomAttributesScopeConfig extends ScopeConfigBase { - type: 'custom-attributes' + type: 'customAttributes' attributes: CustomAttributeConfig[] } @@ -118,7 +118,7 @@ export interface LocalesConfigCreateBody { } export interface CustomAttributesConfigCreateBody { - type: 'custom-attributes' + type: 'customAttributes' attributes: CustomAttributeConfig[] } From 76e9d92ac813c7c3f50476205f1333a192952a5d Mon Sep 17 00:00:00 2001 From: sharmeebuilds Date: Wed, 6 May 2026 13:14:46 +0530 Subject: [PATCH 13/23] Support human readable label for custom attributes --- .../ConfigManagement/ConfigManagement.tsx | 22 ++++++++++++++++++- .../EventForm/CustomAttributesComponent.tsx | 2 +- web-src/src/types/configApi.ts | 1 + web-src/src/types/domain.ts | 1 + 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/web-src/src/pages/ConfigManagement/ConfigManagement.tsx b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx index 822dfa9..b0543c3 100644 --- a/web-src/src/pages/ConfigManagement/ConfigManagement.tsx +++ b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx @@ -188,6 +188,7 @@ export const ConfigManagement: React.FC = () => { const [isAttrFormOpen, setIsAttrFormOpen] = useState(false) const [editingAttr, setEditingAttr] = useState(null) const [attrFormName, setAttrFormName] = useState('') + const [attrFormLabel, setAttrFormLabel] = useState('') const [attrFormInputType, setAttrFormInputType] = useState('text') const [attrFormValues, setAttrFormValues] = useState([]) const [attrFormEnabled, setAttrFormEnabled] = useState(true) @@ -747,6 +748,7 @@ export const ConfigManagement: React.FC = () => { const openAttrCreate = useCallback(() => { setEditingAttr(null) setAttrFormName('') + setAttrFormLabel('') setAttrFormInputType('text') setAttrFormValues([]) setAttrFormEnabled(true) @@ -756,6 +758,7 @@ export const ConfigManagement: React.FC = () => { const openAttrEdit = useCallback((attr: CustomAttributeConfig) => { setEditingAttr(attr) setAttrFormName(attr.name) + setAttrFormLabel(attr.label ?? '') setAttrFormInputType(attr.inputType) setAttrFormValues(attr.values.map(v => ({ ...v, label: v.label ?? '' }))) setAttrFormEnabled(attr.enabled) @@ -771,6 +774,7 @@ export const ConfigManagement: React.FC = () => { const newAttr: CustomAttributeConfig = { attributeId: editingAttr?.attributeId || generateUUID(), name: attrFormName.trim(), + label: attrFormLabel.trim() || undefined, inputType: attrFormInputType, enabled: attrFormEnabled, values: attrFormValues @@ -822,7 +826,7 @@ export const ConfigManagement: React.FC = () => { } finally { setIsSaving(false) } - }, [selectedScopeId, attrFormName, attrFormInputType, attrFormValues, attrFormEnabled, editingAttr, customAttrsConfig, apiService, toast, loadConfigs]) + }, [selectedScopeId, attrFormName, attrFormLabel, attrFormInputType, attrFormValues, attrFormEnabled, editingAttr, customAttrsConfig, apiService, toast, loadConfigs]) const handleDeleteAttr = useCallback(async (attr: CustomAttributeConfig) => { if (!selectedScopeId || !customAttrsConfig) return @@ -1152,6 +1156,15 @@ export const ConfigManagement: React.FC = () => { const attrColumns = useMemo(() => [ { key: 'name', name: 'NAME', width: 200, sortable: true }, + { + key: 'label', + name: 'LABEL', + width: 200, + sortable: true, + render: (item: CustomAttributeConfig) => ( + {item.label || '-'} + ), + }, { key: 'enabled', name: 'ENABLED', @@ -2186,6 +2199,13 @@ export const ConfigManagement: React.FC = () => { isRequired autoFocus /> + Enabled diff --git a/web-src/src/pages/EventForm/CustomAttributesComponent.tsx b/web-src/src/pages/EventForm/CustomAttributesComponent.tsx index eee2630..2482dc8 100644 --- a/web-src/src/pages/EventForm/CustomAttributesComponent.tsx +++ b/web-src/src/pages/EventForm/CustomAttributesComponent.tsx @@ -331,7 +331,7 @@ export const CustomAttributesComponent: React.FC = () => { {index > 0 && }
- {attr.name} + {attr.label || attr.name} {attr.isRequired && attr.inputType === 'multi-select' && ( (Required) )} diff --git a/web-src/src/types/configApi.ts b/web-src/src/types/configApi.ts index b3e4d9f..c4114e4 100644 --- a/web-src/src/types/configApi.ts +++ b/web-src/src/types/configApi.ts @@ -94,6 +94,7 @@ export interface CustomAttributeValue { export interface CustomAttributeConfig { attributeId: string + label?: string name: string inputType: CustomAttributeInputType enabled: boolean diff --git a/web-src/src/types/domain.ts b/web-src/src/types/domain.ts index 813d079..da8f348 100644 --- a/web-src/src/types/domain.ts +++ b/web-src/src/types/domain.ts @@ -646,6 +646,7 @@ export interface EventCustomAttributeValue { valueId?: string value: string displayOrder?: number + label?: string } // Agenda Item From e8bbe6f09e0141282bfe242d3cf3ca8e3be8d967 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Wed, 6 May 2026 10:59:57 -0700 Subject: [PATCH 14/23] feat(event-form): dual-source RSVP fields and per-option UI - Prefer scope RSVP config via getConfigsForScope; fall back to legacy cloud JSON (configService + mapLegacyRsvpConfigToFormFields) when scope has no fields - Add RsvpFieldOptionSelectionState on EventFormData for select/multi-select option order and include toggles (client-only until ESP exposes a contract) - S2 Disclosure for option lists; HTML5 DnD reorder; auto-remove field when all options disabled; reset option state when field hidden - RegistrationConfigComponent wires cloudType and rsvpOptionSelections patch handler - Hydrate rsvpOptionSelections as {} from API; TODO(PIM) on save path for future granular serialization Co-authored-by: Cursor --- web-src/src/contexts/EventFormContext.tsx | 1 + web-src/src/hooks/useEventFormSave.ts | 3 +- .../EventForm/RegistrationConfigComponent.tsx | 18 + .../EventForm/RegistrationFieldsComponent.tsx | 415 +++++++++++++----- web-src/src/types/domain.ts | 13 + web-src/src/utils/eventFormMappers.ts | 1 + web-src/src/utils/rsvpFieldDefinitions.ts | 90 ++++ 7 files changed, 425 insertions(+), 116 deletions(-) create mode 100644 web-src/src/utils/rsvpFieldDefinitions.ts diff --git a/web-src/src/contexts/EventFormContext.tsx b/web-src/src/contexts/EventFormContext.tsx index e6e9bda..52569fa 100644 --- a/web-src/src/contexts/EventFormContext.tsx +++ b/web-src/src/contexts/EventFormContext.tsx @@ -222,6 +222,7 @@ export const createDefaultFormData = (): EventFormData => ({ marketoFormUrl: '', visibleRsvpFields: [], requiredRsvpFields: [], + rsvpOptionSelections: {}, images: [], profiles: [], communityForumUrl: '', diff --git a/web-src/src/hooks/useEventFormSave.ts b/web-src/src/hooks/useEventFormSave.ts index 59521c9..2f24254 100644 --- a/web-src/src/hooks/useEventFormSave.ts +++ b/web-src/src/hooks/useEventFormSave.ts @@ -311,7 +311,8 @@ export function useEventFormSave() { } } - // RSVP form fields + // RSVP form fields — field-level visibility only until ESP supports granular option payloads. + // TODO(PIM): serialize rsvpOptionSelections when event API exposes per-option RSVP selection. if (mergedData.visibleRsvpFields || mergedData.requiredRsvpFields) { payload.rsvpFormFields = { visible: mergedData.visibleRsvpFields || [], diff --git a/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx b/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx index 3f5bb8a..694c471 100644 --- a/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx +++ b/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx @@ -18,6 +18,7 @@ import { HeadingWithTooltip, RichTextEditor } from '../../components/shared' import InfoCircle from "@react-spectrum/s2/icons/InfoCircle" import { RegistrationFieldsComponent } from './RegistrationFieldsComponent' import { useEventFormComponent } from '../../hooks/useEventFormComponent' +import type { RsvpFieldOptionSelectionState } from '../../types/domain' /** * RegistrationConfigComponent - Manages event registration settings @@ -49,6 +50,7 @@ export const RegistrationConfigComponent: React.FC = () => { const marketoFormUrl = formData.marketoFormUrl || '' const visibleRsvpFields = formData.visibleRsvpFields || [] const requiredRsvpFields = formData.requiredRsvpFields || [] + const rsvpOptionSelections = formData.rsvpOptionSelections || {} // ============================================================================ // LOCAL STATE @@ -107,6 +109,19 @@ export const RegistrationConfigComponent: React.FC = () => { const handleRequiredFieldsChange = (fields: string[]) => { updateFormData({ requiredRsvpFields: fields }) } + + const handleRsvpOptionSelectionsChange = (patch: Record) => { + const prev = formData.rsvpOptionSelections || {} + const next = { ...prev } + for (const [key, val] of Object.entries(patch)) { + if (val === null || val === undefined) { + delete next[key] + } else { + next[key] = val + } + } + updateFormData({ rsvpOptionSelections: next }) + } const handleContactHostToggle = (value: boolean) => { setContactHostEnabled(value) @@ -254,12 +269,15 @@ export const RegistrationConfigComponent: React.FC = () => { diff --git a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx index 3fe164b..84f3e40 100644 --- a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx +++ b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx @@ -2,8 +2,17 @@ * */ -import React, { useState, useEffect } from 'react' -import { TextField, RadioGroup, Radio, Text, Switch } from '@react-spectrum/s2' +import React, { useState, useEffect, useCallback } from 'react' +import { + TextField, + RadioGroup, + Radio, + Text, + Switch, + Disclosure, + DisclosureTitle, + DisclosurePanel, +} from '@react-spectrum/s2' import { style } from "@react-spectrum/s2/style" with { type: "macro" } import { HeadingWithTooltip } from '../../components/shared' import { COLORS, SURFACES } from '../../styles/designSystem' @@ -11,7 +20,16 @@ import OpenIn from '@react-spectrum/s2/icons/OpenIn' import Move from '@react-spectrum/s2/icons/Move' import { useGroup } from '../../contexts/GroupContext' import { cachedApi } from '../../services/api' +import { configService } from '../../services/configService' +import { hasRsvpConfig } from '../../config/externalConfigs' import type { RsvpFormField, RsvpScopeConfig } from '../../types/configApi' +import type { RsvpFieldOptionSelectionState } from '../../types/domain' +import { + mapLegacyRsvpConfigToFormFields, + mergeOptionSelectionWithField, + defaultOptionSelectionFromField, + isSelectableField, +} from '../../utils/rsvpFieldDefinitions' /** * Extended field with display info @@ -23,58 +41,88 @@ interface DisplayField { originalIndex: number } +export type RsvpFieldSourceMode = 'scope' | 'legacy' + interface RegistrationFieldsComponentProps { isExperienceCloud: boolean eventType: 'InPerson' | 'Virtual' + cloudType: string visibleFields: string[] requiredFields: string[] registrationType: 'ESP' | 'Marketo' marketoFormUrl?: string + rsvpOptionSelections: Record onVisibleFieldsChange: (fields: string[]) => void onRequiredFieldsChange: (fields: string[]) => void onRegistrationTypeChange: (type: 'ESP' | 'Marketo') => void onMarketoFormUrlChange: (url: string) => void + /** Pass `null` for a field key to remove stored option state */ + onRsvpOptionSelectionsChange: (patch: Record) => void } export const RegistrationFieldsComponent: React.FC = ({ isExperienceCloud, eventType, + cloudType, visibleFields, requiredFields, registrationType, marketoFormUrl = '', + rsvpOptionSelections, onVisibleFieldsChange, onRequiredFieldsChange, onRegistrationTypeChange, - onMarketoFormUrlChange + onMarketoFormUrlChange, + onRsvpOptionSelectionsChange, }) => { const { activeGroup } = useGroup() const [fields, setFields] = useState([]) + const [fieldSourceMode, setFieldSourceMode] = useState('scope') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - // Drag and drop state const [draggedIndex, setDraggedIndex] = useState(null) const [dragOverIndex, setDragOverIndex] = useState(null) - // Fetch RSVP config for the current scope on mount + const [optionDrag, setOptionDrag] = useState<{ fieldName: string; index: number } | null>(null) + const [optionDragOver, setOptionDragOver] = useState<{ fieldName: string; index: number } | null>(null) + + const applyOptionPatch = useCallback((patch: Record) => { + onRsvpOptionSelectionsChange(patch) + }, [onRsvpOptionSelectionsChange]) + useEffect(() => { const scopeId = activeGroup?.scopeId - if (!scopeId) { - setLoading(false) - return - } + const cloudForLegacy = hasRsvpConfig(cloudType) ? cloudType : 'CreativeCloud' const loadFields = async () => { try { setLoading(true) - const result = await cachedApi.getConfigsForScope(scopeId, 'rsvp') - if ('error' in result) { - setError('Failed to load registration field configurations') - return + let nextFields: RsvpFormField[] = [] + let mode: RsvpFieldSourceMode = 'legacy' + + if (scopeId) { + const result = await cachedApi.getConfigsForScope(scopeId, 'rsvp') + if (!('error' in result)) { + const rsvpConfig = result.find(c => c.type === 'rsvp') as RsvpScopeConfig | undefined + const scopeFields = rsvpConfig?.rsvpFormFields ?? [] + if (scopeFields.length > 0) { + nextFields = scopeFields + mode = 'scope' + } + } else { + console.warn('Scope RSVP config request failed; falling back to legacy JSON if available.', result) + } + } + + if (nextFields.length === 0 && hasRsvpConfig(cloudForLegacy)) { + const legacyRows = await configService.getRsvpConfig(cloudForLegacy) + nextFields = mapLegacyRsvpConfigToFormFields(legacyRows) + mode = 'legacy' } - const rsvpConfig = result.find(c => c.type === 'rsvp') as RsvpScopeConfig | undefined - setFields(rsvpConfig?.rsvpFormFields ?? []) + + setFields(nextFields) + setFieldSourceMode(mode) setError(null) } catch (err) { setError('Failed to load registration field configurations') @@ -85,13 +133,11 @@ export const RegistrationFieldsComponent: React.FC f.field) const mandatedFieldNames = validFields.filter(f => f.required).map(f => f.field) - // Build display fields list with original order preserved const allDisplayFields: DisplayField[] = validFields.map((f, idx) => ({ fieldName: f.field, label: f.label, @@ -99,8 +145,6 @@ export const RegistrationFieldsComponent: React.FC { const aIsSelected = visibleFields.includes(a.fieldName) const bIsSelected = visibleFields.includes(b.fieldName) @@ -108,39 +152,42 @@ export const RegistrationFieldsComponent: React.FC fields.filter(f => f.field).find(f => f.field === fieldName), + [fields] + ) + + const getEffectiveOptionState = useCallback((fieldName: string): RsvpFieldOptionSelectionState | null => { + const def = getFieldDef(fieldName) + if (!def || !isSelectableField(def)) return null + return mergeOptionSelectionWithField(def, rsvpOptionSelections[fieldName]) + }, [getFieldDef, rsvpOptionSelections]) + useEffect(() => { if (mandatedFieldNames.length === 0) return - // Check if any mandated fields are missing from visibleFields const missingVisibleMandated = mandatedFieldNames.filter((f) => !visibleFields.includes(f)) if (missingVisibleMandated.length > 0) { const newVisibleFields = [...visibleFields, ...missingVisibleMandated] onVisibleFieldsChange(newVisibleFields) - // Also ensure requiredFields is ordered consistently with newVisibleFields const missingRequiredMandated = mandatedFieldNames.filter((f) => !requiredFields.includes(f)) if (missingRequiredMandated.length > 0) { - // Build required array in the same order as visible const newRequiredFields = newVisibleFields.filter((f) => requiredFields.includes(f) || missingRequiredMandated.includes(f) ) onRequiredFieldsChange(newRequiredFields) } } else { - // Check if any mandated fields are missing from requiredFields (visible was already complete) const missingRequiredMandated = mandatedFieldNames.filter((f) => !requiredFields.includes(f)) if (missingRequiredMandated.length > 0) { - // Build required array in the same order as visible const newRequiredFields = visibleFields.filter((f) => requiredFields.includes(f) || missingRequiredMandated.includes(f) ) @@ -149,13 +196,8 @@ export const RegistrationFieldsComponent: React.FC { const field = sortedDisplayFields[displayIndex] - // Only allow dragging selected (visible) fields if (!visibleFields.includes(field.fieldName)) return setDraggedIndex(displayIndex) @@ -166,7 +208,6 @@ export const RegistrationFieldsComponent: React.FC { e.preventDefault() const field = sortedDisplayFields[displayIndex] - // Only allow dropping on selected (visible) fields if (!visibleFields.includes(field.fieldName)) return e.dataTransfer.dropEffect = 'move' @@ -191,14 +232,12 @@ export const RegistrationFieldsComponent: React.FC requiredFields.includes(f)) onRequiredFieldsChange(newRequiredFields) @@ -221,41 +259,119 @@ export const RegistrationFieldsComponent: React.FC { if (checked) { const newVisibleFields = [...visibleFields, fieldName] onVisibleFieldsChange(newVisibleFields) - // Re-order requiredFields to match visible order (in case field was previously required) const newRequiredFields = newVisibleFields.filter((f) => requiredFields.includes(f)) if (newRequiredFields.length !== requiredFields.length || !newRequiredFields.every((f, i) => f === requiredFields[i])) { onRequiredFieldsChange(newRequiredFields) } + const def = getFieldDef(fieldName) + if (fieldSourceMode === 'scope' && def && isSelectableField(def)) { + applyOptionPatch({ [fieldName]: defaultOptionSelectionFromField(def) }) + } } else { - // Remove from both visible and required onVisibleFieldsChange(visibleFields.filter((f) => f !== fieldName)) onRequiredFieldsChange(requiredFields.filter((f) => f !== fieldName)) + applyOptionPatch({ [fieldName]: null }) } } const handleRequiredToggle = (fieldName: string, checked: boolean) => { if (checked) { - // Add to both visible and required const newVisible = visibleFields.includes(fieldName) ? visibleFields : [...visibleFields, fieldName] onVisibleFieldsChange(newVisible) - // Insert the field in the required array at the position matching its order in visibleFields const newRequired = newVisible.filter((f) => requiredFields.includes(f) || f === fieldName) onRequiredFieldsChange(newRequired) + const def = getFieldDef(fieldName) + if (fieldSourceMode === 'scope' && def && isSelectableField(def) && !rsvpOptionSelections[fieldName]) { + applyOptionPatch({ [fieldName]: defaultOptionSelectionFromField(def) }) + } } else { - // Remove from required only onRequiredFieldsChange(requiredFields.filter((f) => f !== fieldName)) } } + const handleOptionEnabledToggle = (fieldName: string, optionValue: string, enabled: boolean) => { + const def = getFieldDef(fieldName) + if (!def || !isSelectableField(def)) return + + const cur = mergeOptionSelectionWithField(def, rsvpOptionSelections[fieldName]) + const disabled = new Set(cur.disabledValues) + if (enabled) { + disabled.delete(optionValue) + } else { + disabled.add(optionValue) + } + + const enabledCount = cur.order.filter(v => !disabled.has(v)).length + if (enabledCount === 0) { + handleVisibleToggle(fieldName, false) + return + } + + applyOptionPatch({ + [fieldName]: { order: [...cur.order], disabledValues: Array.from(disabled) } + }) + } + + const handleOptionDragStart = (e: React.DragEvent, fieldName: string, displayIdx: number) => { + setOptionDrag({ fieldName, index: displayIdx }) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', `${fieldName}:${displayIdx}`) + } + + const handleOptionDragOver = (e: React.DragEvent, fieldName: string, displayIdx: number) => { + e.preventDefault() + if (!optionDrag || optionDrag.fieldName !== fieldName) return + e.dataTransfer.dropEffect = 'move' + if (optionDrag.index !== displayIdx) { + setOptionDragOver({ fieldName, index: displayIdx }) + } + } + + const handleOptionDragLeave = () => { + setOptionDragOver(null) + } + + const handleOptionDrop = (e: React.DragEvent, fieldName: string, dropIdx: number) => { + e.preventDefault() + if (!optionDrag || optionDrag.fieldName !== fieldName) { + setOptionDrag(null) + setOptionDragOver(null) + return + } + const def = getFieldDef(fieldName) + if (!def || !isSelectableField(def)) { + setOptionDrag(null) + setOptionDragOver(null) + return + } + + const cur = mergeOptionSelectionWithField(def, rsvpOptionSelections[fieldName]) + const from = optionDrag.index + if (from === dropIdx) { + setOptionDrag(null) + setOptionDragOver(null) + return + } + + const order = [...cur.order] + const [moved] = order.splice(from, 1) + order.splice(dropIdx, 0, moved) + + applyOptionPatch({ [fieldName]: { order, disabledValues: [...cur.disabledValues] } }) + setOptionDrag(null) + setOptionDragOver(null) + } + + const handleOptionDragEnd = () => { + setOptionDrag(null) + setOptionDragOver(null) + } + const renderBasicFormTable = () => { const mandatedLabels = mandatedFieldNames .map(name => allDisplayFields.find(f => f.fieldName === name)?.label ?? name) @@ -269,6 +385,12 @@ export const RegistrationFieldsComponent: React.FC )} + {fieldSourceMode === 'legacy' && ( + + Using cloud RSVP field list (legacy JSON). Connect a scope RSVP config in Cloud Management for full field types and option controls. + + )} +
- {/* Header row */}
MAKE IT REQUIRED - {/* Drag handle header - empty placeholder */}
- {/* Field rows */}
{sortedDisplayFields.map((displayField, displayIndex) => { const { fieldName, label, isMandated } = displayField @@ -307,79 +426,145 @@ export const RegistrationFieldsComponent: React.FC handleDragStart(e, displayIndex)} - onDragOver={(e) => handleDragOver(e, displayIndex)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, displayIndex)} - onDragEnd={handleDragEnd} - style={{ - display: 'grid', - gridTemplateColumns: '1fr 1fr 1fr 40px', - gap: '16px', - alignItems: 'center', - padding: '12px 16px', - borderRadius: '6px', - backgroundColor: isDragging - ? SURFACES.PILL_BG - : isVisible - ? SURFACES.CANVAS - : 'transparent', - border: isDragOver - ? `2px solid ${SURFACES.SELECTED_RING}` - : isVisible - ? `1px solid ${SURFACES.BORDER}` - : '1px solid transparent', - opacity: isDragging ? 0.5 : 1, - transition: 'border-color 0.2s, background-color 0.2s', - cursor: canDrag ? 'default' : 'default' - }} - > - - {label} - {isMandated && ( - - (Always required) - - )} - - handleVisibleToggle(fieldName, checked)} - isDisabled={isMandated} - > - Appears on form - - handleRequiredToggle(fieldName, checked)} - isDisabled={!isVisible || isMandated} - > - Required field - - {/* Drag handle - only visible for selected fields */} +
handleDragStart(e, displayIndex)} + onDragOver={(e) => handleDragOver(e, displayIndex)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, displayIndex)} + onDragEnd={handleDragEnd} style={{ - display: 'flex', - justifyContent: 'center', + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr 40px', + gap: '16px', alignItems: 'center', - cursor: canDrag ? 'grab' : 'default', - color: canDrag ? COLORS.GRAY_600 : SURFACES.BORDER, - opacity: canDrag ? 1 : 0.3 + padding: '12px 16px', + borderRadius: '6px', + backgroundColor: isDragging + ? SURFACES.PILL_BG + : isVisible + ? SURFACES.CANVAS + : 'transparent', + border: isDragOver + ? `2px solid ${SURFACES.SELECTED_RING}` + : isVisible + ? `1px solid ${SURFACES.BORDER}` + : '1px solid transparent', + opacity: isDragging ? 0.5 : 1, + transition: 'border-color 0.2s, background-color 0.2s', + cursor: canDrag ? 'default' : 'default' }} > - + + {label} + {isMandated && ( + + (Always required) + + )} + + handleVisibleToggle(fieldName, checked)} + isDisabled={isMandated} + > + Appears on form + + handleRequiredToggle(fieldName, checked)} + isDisabled={!isVisible || isMandated} + > + Required field + +
+ +
+ + {showOptionEditor && optState && isVisible && ( +
+ + + Options ({optState.order.length}) + + +
+ {optState.order.map((optValue, optDisplayIdx) => { + const optLabel = fieldDef?.options?.find(o => o.value === optValue)?.label ?? optValue + const optEnabled = !optState.disabledValues.includes(optValue) + const oDragging = optionDrag?.fieldName === fieldName && optionDrag.index === optDisplayIdx + const oOver = optionDragOver?.fieldName === fieldName && optionDragOver.index === optDisplayIdx + + return ( +
handleOptionDragStart(e, fieldName, optDisplayIdx)} + onDragOver={(e) => handleOptionDragOver(e, fieldName, optDisplayIdx)} + onDragLeave={handleOptionDragLeave} + onDrop={(e) => handleOptionDrop(e, fieldName, optDisplayIdx)} + onDragEnd={handleOptionDragEnd} + style={{ + display: 'grid', + gridTemplateColumns: '1fr 1fr 40px', + gap: 12, + alignItems: 'center', + padding: '8px 12px', + borderRadius: 6, + backgroundColor: oDragging ? SURFACES.PILL_BG : SURFACES.CANVAS, + border: oOver ? `2px solid ${SURFACES.SELECTED_RING}` : `1px solid ${SURFACES.BORDER}`, + opacity: oDragging ? 0.6 : 1 + }} + > + {optLabel} + handleOptionEnabledToggle(fieldName, optValue, checked)} + > + Include option + +
+ +
+
+ ) + })} +
+
+
+
+ )}
) })} diff --git a/web-src/src/types/domain.ts b/web-src/src/types/domain.ts index da8f348..d7f4873 100644 --- a/web-src/src/types/domain.ts +++ b/web-src/src/types/domain.ts @@ -556,6 +556,14 @@ export interface VenueData { useAlternativeVenueName?: boolean // Whether to show/use alternative name field } +/** Per-field RSVP option UI state (scope-config select / multi-select). */ +export interface RsvpFieldOptionSelectionState { + /** Option `value` strings in display order */ + order: string[] + /** Option values toggled off (all others are on) */ + disabledValues: string[] +} + // Comprehensive Event Form Data export interface EventFormData { // Step 1: Basic Info @@ -609,6 +617,11 @@ export interface EventFormData { rsvpFormFields?: Record visibleRsvpFields?: string[] requiredRsvpFields?: string[] + /** + * Client-side RSVP option order and toggles for select / multi-select fields (scope config). + * Not sent on event save until ESP supports granular RSVP payloads — see useEventFormSave TODO(PIM). + */ + rsvpOptionSelections?: Record // Step 6: Images images?: EventImageData[] diff --git a/web-src/src/utils/eventFormMappers.ts b/web-src/src/utils/eventFormMappers.ts index c1363af..b873fac 100644 --- a/web-src/src/utils/eventFormMappers.ts +++ b/web-src/src/utils/eventFormMappers.ts @@ -159,6 +159,7 @@ export function mapApiResponseToFormData(event: EventApiResponse, locale: string marketoFormUrl: event.registration?.type === 'Marketo' ? (event.registration.formData || '') : '', visibleRsvpFields: event.rsvpFormFields?.visible || [], requiredRsvpFields: event.rsvpFormFields?.required || [], + rsvpOptionSelections: {}, images: event.images || [], profiles: mapSpeakersToProfiles(event.speakers || [], locale), communityForumUrl: cta?.url || '', diff --git a/web-src/src/utils/rsvpFieldDefinitions.ts b/web-src/src/utils/rsvpFieldDefinitions.ts new file mode 100644 index 0000000..a05b1cf --- /dev/null +++ b/web-src/src/utils/rsvpFieldDefinitions.ts @@ -0,0 +1,90 @@ +/* +* +*/ + +import type { RsvpFormField, RsvpFieldType } from '../types/configApi' +import type { RsvpConfigField } from '../types/attendee' +import type { RsvpFieldOptionSelectionState } from '../types/domain' + +const LEGACY_EXCLUDED_TYPES = new Set(['submit', 'button', 'hidden']) + +function inferFieldType(raw: string | undefined): RsvpFieldType { + const t = (raw || 'text').toLowerCase().replace(/\s+/g, '') + if (t === 'select') return 'select' + if (t === 'multi-select' || t === 'multiselect') return 'multi-select' + if (t === 'email') return 'email' + if (t === 'phone' || t === 'tel') return 'phone' + return 'text' +} + +function parseLegacyOptions(optionsStr: string | undefined): { value: string; label: string }[] { + if (!optionsStr || typeof optionsStr !== 'string') return [] + return optionsStr + .split(',') + .map(s => s.trim()) + .filter(Boolean) + .map((label, i) => ({ + value: `opt_${i}_${label.replace(/\s+/g, '_').slice(0, 40)}`, + label + })) +} + +/** + * Map external cloud JSON RSVP rows to the same shape used by scope RSVP configs. + */ +export function mapLegacyRsvpConfigToFormFields(rows: RsvpConfigField[]): RsvpFormField[] { + return rows + .filter(r => { + const f = r.Field?.trim() + if (!f) return false + const ty = (r.Type || '').toLowerCase() + return !LEGACY_EXCLUDED_TYPES.has(ty) + }) + .map(r => { + const type = inferFieldType(r.Type) + const options = type === 'select' || type === 'multi-select' + ? parseLegacyOptions(r.Options) + : [] + return { + field: r.Field.trim(), + label: (r.Label && r.Label.trim()) || r.Field.trim(), + placeholder: (r.Placeholder && r.Placeholder.trim()) || '', + type, + required: r.Required === 'x' || r.Required === 'X', + options, + rules: '', + default: '', + displayAs: '' + } satisfies RsvpFormField + }) +} + +export function defaultOptionSelectionFromField(field: RsvpFormField): RsvpFieldOptionSelectionState { + const order = (field.options || []).map(o => o.value) + return { order, disabledValues: [] } +} + +/** + * Reconcile stored selections with the current field definition (handles config edits). + */ +export function mergeOptionSelectionWithField( + field: RsvpFormField, + stored: RsvpFieldOptionSelectionState | undefined +): RsvpFieldOptionSelectionState { + const defaults = defaultOptionSelectionFromField(field) + const validVals = new Set((field.options || []).map(o => o.value)) + if (validVals.size === 0) return defaults + + if (!stored) return defaults + + const order = stored.order.filter(v => validVals.has(v)) + for (const v of defaults.order) { + if (!order.includes(v)) order.push(v) + } + const disabledValues = stored.disabledValues.filter(v => validVals.has(v)) + return { order, disabledValues } +} + +export function isSelectableField(field: RsvpFormField): boolean { + return (field.type === 'select' || field.type === 'multi-select') && (field.options?.length ?? 0) > 0 +} From b50a861d1fbc0ca9f019bf6132fba8f4231870d5 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Wed, 6 May 2026 11:00:05 -0700 Subject: [PATCH 15/23] chore: add PIM pod workflow docs and ignore local .pim state - Document pim context/report usage and pod binding in CLAUDE.md - Add Claude Code sync command for PIM context pull - Gitignore .pim/ directory for local PIM state Co-authored-by: Cursor --- .claude/commands/sync.md | 12 ++++++ .gitignore | 3 ++ CLAUDE.md | 93 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 .claude/commands/sync.md diff --git a/.claude/commands/sync.md b/.claude/commands/sync.md new file mode 100644 index 0000000..f23a0b0 --- /dev/null +++ b/.claude/commands/sync.md @@ -0,0 +1,12 @@ +Pull the latest PIM pod context and summarize what's relevant to your current work. + +Steps: +1. Run: pim context --pod pod-emc-s27-configsservice-d2506b --scope frontend --diff +2. If no previous context exists, run without --diff: pim context --pod pod-emc-s27-configsservice-d2506b --scope frontend --brief +3. Parse the output and summarize: + - Current pod pressure and day number + - Any open conflicts that affect scope "frontend" + - Recent updates from other agents that you should be aware of + - Relevant org learnings +4. If conflict pressure >= 0.6, warn about potential merge restrictions +5. If there are open conflicts in your scope, list them and recommend reviewing before proceeding diff --git a/.gitignore b/.gitignore index 6a02b29..2bc0b85 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ coverage # logs folder for aio-run-detached logs + +# PIM local state +.pim/ diff --git a/CLAUDE.md b/CLAUDE.md index 6650c79..163302c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,3 +110,96 @@ Set by build-time `ENVIRONMENT` variable (not hostname-based). Values: `dev` (de | Route `/events/new` | `/events/new/:eventType` | Always validate against actual source code. + + + + +## PIM — Pod Agent Protocol + +This project is connected to PIM pod `pod-emc-s27-configsservice-d2506b`. +PIM server: `https://d1ygncl0yqo6sv.cloudfront.net` + +### Getting Current Pod Context + +When you need to understand the current state of the pod before making decisions +or starting work in an unfamiliar area, pull context with: + +```bash +pim context --pod pod-emc-s27-configsservice-d2506b --scope frontend +``` + +Use `--brief` for a quick summary or `--diff` to see only what changed since +your last pull. If conflict pressure is >= 0.6, check open conflicts before +proceeding in contested areas. + +### Automatic Reporting + +Context updates are automatically reported to PIM when you: +- **Make a git commit** — via post-commit hook (captures subject, body, changed files) +- **Create a pull request** — via Claude Code hook (captures PR URL and title) + +You do not need to manually report routine progress — it flows automatically. + +### Manual Reporting + +Report these manually using `pim report` or the MCP `submit_context_update` tool: +- **Blockers**: When you are blocked by another area or dependency +- **Decisions**: When you make a significant architectural or design decision +- **Spec changes**: When you discover the spec needs to change +- **Questions**: When you need input from another role + +Example: +```bash +pim report --pod pod-emc-s27-configsservice-d2506b --type decision --scope frontend \ + --summary "Chose Redis over Memcached for session cache" \ + --details "Redis supports pub/sub which we need for real-time invalidation..." +``` + +### Quality Guidelines + +- Summaries should be specific and actionable (avoid "made progress" or "working on it") +- Include file paths, function names, or API endpoints when relevant +- Declare blockers and input requests — this triggers PIM's escalation system +- Artifacts (changed files) are automatically included with commit reports + +### Conflict Awareness + +- Check pod pressure with `pim context --pod pod-emc-s27-configsservice-d2506b --brief` +- If pressure is >= 0.8, ingestion is halted — resolve conflicts first +- When your work overlaps with another area, PIM will detect it automatically + +### PIM MCP Tools + +If the PIM MCP server is configured in Claude Code, you can use these tools +directly instead of CLI commands. They cover the same operations plus additional +querying and management capabilities. + +**Context & Session** + +| Tool | When to use | +|------|-------------| +| `get_agent_session_context` | Pull pod state, living doc, conflicts, and token-budgeted org learnings in one call — the MCP equivalent of `pim context` | +| `context_search` | Search external sources (Slack archives, Jira, Confluence, GitHub, git) via PIM's aggregated search — no separate Slack/Jira MCPs needed | +| `query_knowledge` | Search the org knowledge graph for historical precedents and resolved decisions | + +**Reporting** + +| Tool | When to use | +|------|-------------| +| `submit_context_update` | Report progress, blockers, decisions, spec changes, or questions | + +**Conflicts** + +| Tool | When to use | +|------|-------------| +| `get_conflict_details` | Inspect a specific open conflict and its suggested resolutions | +| `resolve_conflict` | Mark a conflict as resolved with a chosen approach | + +**Observability** + +| Tool | When to use | +|------|-------------| +| `render_pod_dashboard` | Get a full interactive React artifact showing pod health, conflicts, feed, and live doc | +| `list_pods` | See all active pods in the org | + + From d5d16e8a0a21185f26525f6582572ceb2cd17975 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Wed, 6 May 2026 11:21:21 -0700 Subject: [PATCH 16/23] Adding new pim related fixes --- .claude/settings.json | 24 ++++++++++++++++++++++++ .gitignore | 1 + 2 files changed, 25 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index bd1d29c..791e95a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -28,5 +28,29 @@ "Bash(npm run deploy:stage)", "Bash(npm run deploy:prod)" ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"/Users/cod87753/Code/ai-council/packages/cli/dist/hooks/claude-code-post-tool.cjs\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"/Users/cod87753/Code/ai-council/packages/cli/dist/hooks/claude-code-pre-tool.cjs\"" + } + ] + } + ] } } diff --git a/.gitignore b/.gitignore index 2bc0b85..b3f05da 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ logs # PIM local state .pim/ +.pim.json From fa210b82e7c0ea1372394e666b7b1d1a0ab40668 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Wed, 6 May 2026 12:13:33 -0700 Subject: [PATCH 17/23] Update RegistrationFieldsComponent.tsx --- .../EventForm/RegistrationFieldsComponent.tsx | 58 ++++++++++++------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx index 84f3e40..4da245c 100644 --- a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx +++ b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx @@ -87,6 +87,8 @@ export const RegistrationFieldsComponent: React.FC(null) const [optionDragOver, setOptionDragOver] = useState<{ fieldName: string; index: number } | null>(null) + const [expandedOptions, setExpandedOptions] = useState>(new Set()) + const applyOptionPatch = useCallback((patch: Record) => { onRsvpOptionSelectionsChange(patch) }, [onRsvpOptionSelectionsChange]) @@ -200,6 +202,7 @@ export const RegistrationFieldsComponent: React.FC +
handleDragOver(e, displayIndex)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, displayIndex)} + style={{ + borderRadius: '6px', + backgroundColor: isDragging + ? SURFACES.PILL_BG + : isVisible + ? SURFACES.CANVAS + : 'transparent', + border: isDragOver + ? `2px solid ${SURFACES.SELECTED_RING}` + : isVisible + ? `1px solid ${SURFACES.BORDER}` + : '1px solid transparent', + opacity: isDragging ? 0.5 : 1, + transition: 'border-color 0.2s, background-color 0.2s', + }} + >
handleDragStart(e, displayIndex)} - onDragOver={(e) => handleDragOver(e, displayIndex)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, displayIndex)} onDragEnd={handleDragEnd} style={{ display: 'grid', @@ -445,20 +467,6 @@ export const RegistrationFieldsComponent: React.FC @@ -505,8 +513,18 @@ export const RegistrationFieldsComponent: React.FC {showOptionEditor && optState && isVisible && ( -
- +
+ { + setExpandedOptions(prev => { + const next = new Set(prev) + if (expanded) next.add(fieldName) + else next.delete(fieldName) + return next + }) + }} + > Options ({optState.order.length}) From eaab258cc318873ab704423474e34142efd67e2d Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Wed, 6 May 2026 12:22:04 -0700 Subject: [PATCH 18/23] feat(rsvp): move options panel inside field card with inline toggle - Options list now renders inside the field card border rather than below it - Replaced Disclosure/DisclosureTitle trigger with an inline ListBulleted icon in the field row (5th grid column) - Icon is only shown for fields that have selectable options; absent otherwise - Icon highlights in selection-ring color when panel is expanded - Starting a field drag collapses all open option panels (mutually exclusive) - Removed redundant borders from option rows; drag-over ring retained Co-Authored-By: Claude Sonnet 4.6 --- .../EventForm/RegistrationFieldsComponent.tsx | 166 ++++++++++-------- 1 file changed, 92 insertions(+), 74 deletions(-) diff --git a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx index 4da245c..2921fd9 100644 --- a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx +++ b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx @@ -9,15 +9,13 @@ import { Radio, Text, Switch, - Disclosure, - DisclosureTitle, - DisclosurePanel, } from '@react-spectrum/s2' import { style } from "@react-spectrum/s2/style" with { type: "macro" } import { HeadingWithTooltip } from '../../components/shared' import { COLORS, SURFACES } from '../../styles/designSystem' import OpenIn from '@react-spectrum/s2/icons/OpenIn' import Move from '@react-spectrum/s2/icons/Move' +import ListBulleted from '@react-spectrum/s2/icons/ListBulleted' import { useGroup } from '../../contexts/GroupContext' import { cachedApi } from '../../services/api' import { configService } from '../../services/configService' @@ -403,7 +401,7 @@ export const RegistrationFieldsComponent: React.FC
+
@@ -463,7 +462,7 @@ export const RegistrationFieldsComponent: React.FC Required field + {showOptionEditor && optState && isVisible ? ( +
{ + setExpandedOptions(prev => { + const next = new Set(prev) + if (isExpanded) next.delete(fieldName) + else next.add(fieldName) + return next + }) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setExpandedOptions(prev => { + const next = new Set(prev) + if (isExpanded) next.delete(fieldName) + else next.add(fieldName) + return next + }) + } + }} + style={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + cursor: 'pointer', + color: isExpanded ? SURFACES.SELECTED_RING : COLORS.GRAY_600, + }} + > + +
+ ) :
}
- {showOptionEditor && optState && isVisible && ( -
- { - setExpandedOptions(prev => { - const next = new Set(prev) - if (expanded) next.add(fieldName) - else next.delete(fieldName) - return next - }) - }} - > - - Options ({optState.order.length}) - - -
- {optState.order.map((optValue, optDisplayIdx) => { - const optLabel = fieldDef?.options?.find(o => o.value === optValue)?.label ?? optValue - const optEnabled = !optState.disabledValues.includes(optValue) - const oDragging = optionDrag?.fieldName === fieldName && optionDrag.index === optDisplayIdx - const oOver = optionDragOver?.fieldName === fieldName && optionDragOver.index === optDisplayIdx - - return ( -
handleOptionDragStart(e, fieldName, optDisplayIdx)} - onDragOver={(e) => handleOptionDragOver(e, fieldName, optDisplayIdx)} - onDragLeave={handleOptionDragLeave} - onDrop={(e) => handleOptionDrop(e, fieldName, optDisplayIdx)} - onDragEnd={handleOptionDragEnd} - style={{ - display: 'grid', - gridTemplateColumns: '1fr 1fr 40px', - gap: 12, - alignItems: 'center', - padding: '8px 12px', - borderRadius: 6, - backgroundColor: oDragging ? SURFACES.PILL_BG : SURFACES.CANVAS, - border: oOver ? `2px solid ${SURFACES.SELECTED_RING}` : `1px solid ${SURFACES.BORDER}`, - opacity: oDragging ? 0.6 : 1 - }} - > - {optLabel} - handleOptionEnabledToggle(fieldName, optValue, checked)} - > - Include option - -
- -
-
- ) - })} -
-
-
+ {showOptionEditor && optState && isVisible && isExpanded && ( +
+
+ {optState.order.map((optValue, optDisplayIdx) => { + const optLabel = fieldDef?.options?.find(o => o.value === optValue)?.label ?? optValue + const optEnabled = !optState.disabledValues.includes(optValue) + const oDragging = optionDrag?.fieldName === fieldName && optionDrag.index === optDisplayIdx + const oOver = optionDragOver?.fieldName === fieldName && optionDragOver.index === optDisplayIdx + + return ( +
handleOptionDragStart(e, fieldName, optDisplayIdx)} + onDragOver={(e) => handleOptionDragOver(e, fieldName, optDisplayIdx)} + onDragLeave={handleOptionDragLeave} + onDrop={(e) => handleOptionDrop(e, fieldName, optDisplayIdx)} + onDragEnd={handleOptionDragEnd} + style={{ + display: 'grid', + gridTemplateColumns: '1fr 1fr 40px', + gap: 12, + alignItems: 'center', + padding: '8px 12px', + borderRadius: 6, + backgroundColor: oDragging ? SURFACES.PILL_BG : SURFACES.CANVAS, + border: oOver ? `2px solid ${SURFACES.SELECTED_RING}` : 'none', + opacity: oDragging ? 0.6 : 1 + }} + > + {optLabel} + handleOptionEnabledToggle(fieldName, optValue, checked)} + > + Include option + +
+ +
+
+ ) + })} +
)}
From 45a5593ab36fc76677f0009a319160d55dafb3c5 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Wed, 6 May 2026 12:27:08 -0700 Subject: [PATCH 19/23] fix(rsvp): address review findings on options toggle UI Blocking fixes: - Replace div[role=button] with ActionButton isQuiet + onPress to fix drag-start interference and missing Spectrum focus ring - Add e.stopPropagation() to handleOptionDragOver/handleOptionDrop so option drags don't bubble to the outer field-card drop target - Clear optionDrag/optionDragOver in handleDragStart to avoid stale state - Prune expandedOptions when a field is hidden so the panel doesn't auto-reopen on re-show Code quality: - Extract toggleExpanded local fn to deduplicate setExpandedOptions logic - Replace
fallback with null for the empty list-icon column - Wrap mandatedFieldNames in useMemo; use stable ref in useEffect dep array - Make isSelectableField a proper TypeScript type predicate - Add aria-label/aria-hidden to Move drag handle divs - Remove dead font styles from empty header span elements Co-Authored-By: Claude Sonnet 4.6 --- .../EventForm/RegistrationFieldsComponent.tsx | 70 ++++++++----------- web-src/src/utils/rsvpFieldDefinitions.ts | 4 +- 2 files changed, 33 insertions(+), 41 deletions(-) diff --git a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx index 2921fd9..0554ab6 100644 --- a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx +++ b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx @@ -2,13 +2,14 @@ * */ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useMemo } from 'react' import { TextField, RadioGroup, Radio, Text, Switch, + ActionButton, } from '@react-spectrum/s2' import { style } from "@react-spectrum/s2/style" with { type: "macro" } import { HeadingWithTooltip } from '../../components/shared' @@ -135,8 +136,8 @@ export const RegistrationFieldsComponent: React.FC f.field) - const mandatedFieldNames = validFields.filter(f => f.required).map(f => f.field) + const validFields = useMemo(() => fields.filter(f => f.field), [fields]) + const mandatedFieldNames = useMemo(() => validFields.filter(f => f.required).map(f => f.field), [validFields]) const allDisplayFields: DisplayField[] = validFields.map((f, idx) => ({ fieldName: f.field, @@ -194,13 +195,15 @@ export const RegistrationFieldsComponent: React.FC { const field = sortedDisplayFields[displayIndex] if (!visibleFields.includes(field.fieldName)) return setExpandedOptions(new Set()) + setOptionDrag(null) + setOptionDragOver(null) setDraggedIndex(displayIndex) e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', String(displayIndex)) @@ -277,6 +280,7 @@ export const RegistrationFieldsComponent: React.FC f !== fieldName)) onRequiredFieldsChange(requiredFields.filter((f) => f !== fieldName)) applyOptionPatch({ [fieldName]: null }) + setExpandedOptions(prev => { const next = new Set(prev); next.delete(fieldName); return next }) } } @@ -325,6 +329,7 @@ export const RegistrationFieldsComponent: React.FC { + e.stopPropagation() e.preventDefault() if (!optionDrag || optionDrag.fieldName !== fieldName) return e.dataTransfer.dropEffect = 'move' @@ -338,6 +343,7 @@ export const RegistrationFieldsComponent: React.FC { + e.stopPropagation() e.preventDefault() if (!optionDrag || optionDrag.fieldName !== fieldName) { setOptionDrag(null) @@ -415,8 +421,8 @@ export const RegistrationFieldsComponent: React.FC MAKE IT REQUIRED - - +
@@ -433,6 +439,13 @@ export const RegistrationFieldsComponent: React.FC + setExpandedOptions(prev => { + const next = new Set(prev) + if (isExpanded) next.delete(fieldName) + else next.add(fieldName) + return next + }) return (
{showOptionEditor && optState && isVisible ? ( -
{ - setExpandedOptions(prev => { - const next = new Set(prev) - if (isExpanded) next.delete(fieldName) - else next.add(fieldName) - return next - }) - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - setExpandedOptions(prev => { - const next = new Set(prev) - if (isExpanded) next.delete(fieldName) - else next.add(fieldName) - return next - }) - } - }} - style={{ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - cursor: 'pointer', - color: isExpanded ? SURFACES.SELECTED_RING : COLORS.GRAY_600, - }} + aria-expanded={isExpanded} + onPress={toggleExpanded} + UNSAFE_style={{ color: isExpanded ? SURFACES.SELECTED_RING : COLORS.GRAY_600 }} > -
- ) :
} + + ) : null}
- +
@@ -586,6 +575,7 @@ export const RegistrationFieldsComponent: React.FC
- +
) diff --git a/web-src/src/utils/rsvpFieldDefinitions.ts b/web-src/src/utils/rsvpFieldDefinitions.ts index a05b1cf..0f6d759 100644 --- a/web-src/src/utils/rsvpFieldDefinitions.ts +++ b/web-src/src/utils/rsvpFieldDefinitions.ts @@ -85,6 +85,8 @@ export function mergeOptionSelectionWithField( return { order, disabledValues } } -export function isSelectableField(field: RsvpFormField): boolean { +export function isSelectableField( + field: RsvpFormField +): field is RsvpFormField & { options: NonNullable } { return (field.type === 'select' || field.type === 'multi-select') && (field.options?.length ?? 0) > 0 } From 78784b420bc866fb60a7c1868917af94f2efe6cd Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Thu, 7 May 2026 14:47:31 -0700 Subject: [PATCH 20/23] PIM files --- .claude/settings.json | 12 ++++++ CLAUDE.md | 89 +++++++++++++++++++++---------------------- 2 files changed, 55 insertions(+), 46 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 791e95a..6152fc0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -52,5 +52,17 @@ ] } ] + }, + "mcpServers": { + "pim": { + "command": "npx", + "args": [ + "-y", + "@pim/mcp-server@0.1.0" + ], + "env": { + "PIM_API_URL": "https://d1ygncl0yqo6sv.cloudfront.net" + } + } } } diff --git a/CLAUDE.md b/CLAUDE.md index 163302c..ece500d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,19 +119,6 @@ Always validate against actual source code. This project is connected to PIM pod `pod-emc-s27-configsservice-d2506b`. PIM server: `https://d1ygncl0yqo6sv.cloudfront.net` -### Getting Current Pod Context - -When you need to understand the current state of the pod before making decisions -or starting work in an unfamiliar area, pull context with: - -```bash -pim context --pod pod-emc-s27-configsservice-d2506b --scope frontend -``` - -Use `--brief` for a quick summary or `--diff` to see only what changed since -your last pull. If conflict pressure is >= 0.6, check open conflicts before -proceeding in contested areas. - ### Automatic Reporting Context updates are automatically reported to PIM when you: @@ -140,45 +127,16 @@ Context updates are automatically reported to PIM when you: You do not need to manually report routine progress — it flows automatically. -### Manual Reporting - -Report these manually using `pim report` or the MCP `submit_context_update` tool: -- **Blockers**: When you are blocked by another area or dependency -- **Decisions**: When you make a significant architectural or design decision -- **Spec changes**: When you discover the spec needs to change -- **Questions**: When you need input from another role - -Example: -```bash -pim report --pod pod-emc-s27-configsservice-d2506b --type decision --scope frontend \ - --summary "Chose Redis over Memcached for session cache" \ - --details "Redis supports pub/sub which we need for real-time invalidation..." -``` - -### Quality Guidelines - -- Summaries should be specific and actionable (avoid "made progress" or "working on it") -- Include file paths, function names, or API endpoints when relevant -- Declare blockers and input requests — this triggers PIM's escalation system -- Artifacts (changed files) are automatically included with commit reports - -### Conflict Awareness - -- Check pod pressure with `pim context --pod pod-emc-s27-configsservice-d2506b --brief` -- If pressure is >= 0.8, ingestion is halted — resolve conflicts first -- When your work overlaps with another area, PIM will detect it automatically - -### PIM MCP Tools +### PIM MCP Tools (Preferred) -If the PIM MCP server is configured in Claude Code, you can use these tools -directly instead of CLI commands. They cover the same operations plus additional -querying and management capabilities. +If the PIM MCP server is configured in Claude Code, **always use these tools +instead of CLI commands** — they are faster and don't require a shell. **Context & Session** | Tool | When to use | |------|-------------| -| `get_agent_session_context` | Pull pod state, living doc, conflicts, and token-budgeted org learnings in one call — the MCP equivalent of `pim context` | +| `get_agent_session_context` | Pull pod state, living doc, conflicts, and token-budgeted org learnings in one call | | `context_search` | Search external sources (Slack archives, Jira, Confluence, GitHub, git) via PIM's aggregated search — no separate Slack/Jira MCPs needed | | `query_knowledge` | Search the org knowledge graph for historical precedents and resolved decisions | @@ -202,4 +160,43 @@ querying and management capabilities. | `render_pod_dashboard` | Get a full interactive React artifact showing pod health, conflicts, feed, and live doc | | `list_pods` | See all active pods in the org | +### Fallback: CLI Commands + +Use these only when the PIM MCP server is not configured. + +#### Getting Current Pod Context + +```bash +pim context --pod pod-emc-s27-configsservice-d2506b --scope frontend +``` + +Use `--brief` for a quick summary or `--diff` to see only what changed since +your last pull. If conflict pressure is >= 0.6, check open conflicts before +proceeding in contested areas. + +#### Manual Reporting + +Report blockers, decisions, spec changes, and questions manually: + +```bash +pim report --pod pod-emc-s27-configsservice-d2506b --type decision --scope frontend \ + --summary "Chose Redis over Memcached for session cache" \ + --details "Redis supports pub/sub which we need for real-time invalidation..." +``` + +Types: `progress` | `blocker` | `spec_change` | `question` | `decision` + +### Quality Guidelines + +- Summaries should be specific and actionable (avoid "made progress" or "working on it") +- Include file paths, function names, or API endpoints when relevant +- Declare blockers and input requests — this triggers PIM's escalation system +- Artifacts (changed files) are automatically included with commit reports + +### Conflict Awareness + +- Check pod pressure with `pim context --pod pod-emc-s27-configsservice-d2506b --brief` +- If pressure is >= 0.8, ingestion is halted — resolve conflicts first +- When your work overlaps with another area, PIM will detect it automatically + From cd79c4ca8c467fc752cca38b69a4341d0f38a723 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Thu, 7 May 2026 15:05:41 -0700 Subject: [PATCH 21/23] Update CLAUDE.md --- CLAUDE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ece500d..2183128 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ Always validate against actual source code. ## PIM — Pod Agent Protocol -This project is connected to PIM pod `pod-emc-s27-configsservice-d2506b`. +This project is connected to PIM pod `pod-emc-s27-configsservice-follow-9f4cd4`. PIM server: `https://d1ygncl0yqo6sv.cloudfront.net` ### Automatic Reporting @@ -167,7 +167,7 @@ Use these only when the PIM MCP server is not configured. #### Getting Current Pod Context ```bash -pim context --pod pod-emc-s27-configsservice-d2506b --scope frontend +pim context --pod pod-emc-s27-configsservice-follow-9f4cd4 --scope frontend ``` Use `--brief` for a quick summary or `--diff` to see only what changed since @@ -179,7 +179,7 @@ proceeding in contested areas. Report blockers, decisions, spec changes, and questions manually: ```bash -pim report --pod pod-emc-s27-configsservice-d2506b --type decision --scope frontend \ +pim report --pod pod-emc-s27-configsservice-follow-9f4cd4 --type decision --scope frontend \ --summary "Chose Redis over Memcached for session cache" \ --details "Redis supports pub/sub which we need for real-time invalidation..." ``` @@ -195,7 +195,7 @@ Types: `progress` | `blocker` | `spec_change` | `question` | `decision` ### Conflict Awareness -- Check pod pressure with `pim context --pod pod-emc-s27-configsservice-d2506b --brief` +- Check pod pressure with `pim context --pod pod-emc-s27-configsservice-follow-9f4cd4 --brief` - If pressure is >= 0.8, ingestion is halted — resolve conflicts first - When your work overlaps with another area, PIM will detect it automatically From 44984edc7044c37acd0d323ced4e2857aa68e7c9 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Fri, 8 May 2026 15:22:13 -0700 Subject: [PATCH 22/23] Update sync.md --- .claude/commands/sync.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/commands/sync.md b/.claude/commands/sync.md index f23a0b0..3a82c88 100644 --- a/.claude/commands/sync.md +++ b/.claude/commands/sync.md @@ -1,8 +1,8 @@ Pull the latest PIM pod context and summarize what's relevant to your current work. Steps: -1. Run: pim context --pod pod-emc-s27-configsservice-d2506b --scope frontend --diff -2. If no previous context exists, run without --diff: pim context --pod pod-emc-s27-configsservice-d2506b --scope frontend --brief +1. Run: pim context --pod pod-emc-s27-configsservice-follow-9f4cd4 --scope frontend --diff +2. If no previous context exists, run without --diff: pim context --pod pod-emc-s27-configsservice-follow-9f4cd4 --scope frontend --brief 3. Parse the output and summarize: - Current pod pressure and day number - Any open conflicts that affect scope "frontend" From 2220567470ddf0fb5760b69c2b081ad97e12f2f3 Mon Sep 17 00:00:00 2001 From: Sharmee Biswas Date: Thu, 21 May 2026 16:08:10 +0530 Subject: [PATCH 23/23] [MWPW-194613] : RSVP Configs from the configs section (#159) * RSVP Configs from the configs section * retain the TODO comment --- web-src/src/hooks/useEventFormSave.ts | 11 +- .../pages/EventForm/EventTagsComponent.tsx | 2 +- .../EventForm/RegistrationConfigComponent.tsx | 34 +--- .../EventForm/RegistrationFieldsComponent.tsx | 168 ++++++++++-------- web-src/src/types/domain.ts | 4 +- web-src/src/utils/eventFormMappers.ts | 4 +- 6 files changed, 104 insertions(+), 119 deletions(-) diff --git a/web-src/src/hooks/useEventFormSave.ts b/web-src/src/hooks/useEventFormSave.ts index 2f24254..4a251e1 100644 --- a/web-src/src/hooks/useEventFormSave.ts +++ b/web-src/src/hooks/useEventFormSave.ts @@ -311,13 +311,10 @@ export function useEventFormSave() { } } - // RSVP form fields — field-level visibility only until ESP supports granular option payloads. + // RSVP form fields — array order = display order; required/options are per-field overrides. // TODO(PIM): serialize rsvpOptionSelections when event API exposes per-option RSVP selection. - if (mergedData.visibleRsvpFields || mergedData.requiredRsvpFields) { - payload.rsvpFormFields = { - visible: mergedData.visibleRsvpFields || [], - required: mergedData.requiredRsvpFields || [] - } + if (mergedData.rsvpFormFields?.length) { + payload.rsvpFormFields = { fields: mergedData.rsvpFormFields } } // Ensure seriesId is set @@ -505,7 +502,7 @@ export function useEventFormSave() { } response = result as EventApiResponse savedEventId = response.eventId - + // Update context with new event ID and store the response for subsequent updates setEventId(savedEventId) setEventResponse(response) // Store response so modificationTime/creationTime are available diff --git a/web-src/src/pages/EventForm/EventTagsComponent.tsx b/web-src/src/pages/EventForm/EventTagsComponent.tsx index 715af96..5c236c3 100644 --- a/web-src/src/pages/EventForm/EventTagsComponent.tsx +++ b/web-src/src/pages/EventForm/EventTagsComponent.tsx @@ -25,7 +25,7 @@ export const EventTagsComponent: React.FC = () => { } = useEventFormComponent({ componentId: 'event-tags', }) - + const selectedTags = formData.tags || [] // ============================================================================ diff --git a/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx b/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx index 694c471..c6012e7 100644 --- a/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx +++ b/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx @@ -18,7 +18,6 @@ import { HeadingWithTooltip, RichTextEditor } from '../../components/shared' import InfoCircle from "@react-spectrum/s2/icons/InfoCircle" import { RegistrationFieldsComponent } from './RegistrationFieldsComponent' import { useEventFormComponent } from '../../hooks/useEventFormComponent' -import type { RsvpFieldOptionSelectionState } from '../../types/domain' /** * RegistrationConfigComponent - Manages event registration settings @@ -48,9 +47,7 @@ export const RegistrationConfigComponent: React.FC = () => { const rsvpDescription = formData.rsvpDescription || '' const registrationType = formData.registrationType || 'ESP' const marketoFormUrl = formData.marketoFormUrl || '' - const visibleRsvpFields = formData.visibleRsvpFields || [] - const requiredRsvpFields = formData.requiredRsvpFields || [] - const rsvpOptionSelections = formData.rsvpOptionSelections || {} + const rsvpFormFields = formData.rsvpFormFields || [] // ============================================================================ // LOCAL STATE @@ -102,25 +99,8 @@ export const RegistrationConfigComponent: React.FC = () => { updateFormData({ marketoFormUrl: url }) } - const handleVisibleFieldsChange = (fields: string[]) => { - updateFormData({ visibleRsvpFields: fields }) - } - - const handleRequiredFieldsChange = (fields: string[]) => { - updateFormData({ requiredRsvpFields: fields }) - } - - const handleRsvpOptionSelectionsChange = (patch: Record) => { - const prev = formData.rsvpOptionSelections || {} - const next = { ...prev } - for (const [key, val] of Object.entries(patch)) { - if (val === null || val === undefined) { - delete next[key] - } else { - next[key] = val - } - } - updateFormData({ rsvpOptionSelections: next }) + const handleRsvpFormFieldsChange = (fields: { field: string; required?: boolean; options?: string[] }[]) => { + updateFormData({ rsvpFormFields: fields }) } const handleContactHostToggle = (value: boolean) => { @@ -270,14 +250,10 @@ export const RegistrationConfigComponent: React.FC = () => { isExperienceCloud={isExperienceCloud} eventType={isWebinar ? 'Virtual' : 'InPerson'} cloudType={cloudType} - visibleFields={visibleRsvpFields} - requiredFields={requiredRsvpFields} - rsvpOptionSelections={rsvpOptionSelections} + rsvpFormFields={rsvpFormFields} registrationType={registrationType} marketoFormUrl={marketoFormUrl} - onVisibleFieldsChange={handleVisibleFieldsChange} - onRequiredFieldsChange={handleRequiredFieldsChange} - onRsvpOptionSelectionsChange={handleRsvpOptionSelectionsChange} + onRsvpFormFieldsChange={handleRsvpFormFieldsChange} onRegistrationTypeChange={handleRegistrationTypeChange} onMarketoFormUrlChange={handleMarketoFormUrlChange} /> diff --git a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx index 0554ab6..58402e8 100644 --- a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx +++ b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx @@ -30,9 +30,8 @@ import { isSelectableField, } from '../../utils/rsvpFieldDefinitions' -/** - * Extended field with display info - */ +type RsvpFieldEntry = { field: string; required?: boolean; options?: string[] } + interface DisplayField { fieldName: string label: string @@ -46,33 +45,24 @@ interface RegistrationFieldsComponentProps { isExperienceCloud: boolean eventType: 'InPerson' | 'Virtual' cloudType: string - visibleFields: string[] - requiredFields: string[] + rsvpFormFields: RsvpFieldEntry[] registrationType: 'ESP' | 'Marketo' marketoFormUrl?: string - rsvpOptionSelections: Record - onVisibleFieldsChange: (fields: string[]) => void - onRequiredFieldsChange: (fields: string[]) => void + onRsvpFormFieldsChange: (fields: RsvpFieldEntry[]) => void onRegistrationTypeChange: (type: 'ESP' | 'Marketo') => void onMarketoFormUrlChange: (url: string) => void - /** Pass `null` for a field key to remove stored option state */ - onRsvpOptionSelectionsChange: (patch: Record) => void } export const RegistrationFieldsComponent: React.FC = ({ isExperienceCloud, eventType, cloudType, - visibleFields, - requiredFields, + rsvpFormFields, registrationType, marketoFormUrl = '', - rsvpOptionSelections, - onVisibleFieldsChange, - onRequiredFieldsChange, + onRsvpFormFieldsChange, onRegistrationTypeChange, onMarketoFormUrlChange, - onRsvpOptionSelectionsChange, }) => { const { activeGroup } = useGroup() const [fields, setFields] = useState([]) @@ -88,9 +78,14 @@ export const RegistrationFieldsComponent: React.FC>(new Set()) - const applyOptionPatch = useCallback((patch: Record) => { - onRsvpOptionSelectionsChange(patch) - }, [onRsvpOptionSelectionsChange]) + // Internal option selection state — tracks enabled/ordered options per selectable field + const [rsvpOptionSelections, setRsvpOptionSelections] = useState>( + () => Object.fromEntries( + rsvpFormFields + .filter(f => Array.isArray(f.options) && f.options.length > 0) + .map(f => [f.field, { order: f.options!, disabledValues: [] }]) + ) + ) useEffect(() => { const scopeId = activeGroup?.scopeId @@ -147,16 +142,14 @@ export const RegistrationFieldsComponent: React.FC { - const aIsSelected = visibleFields.includes(a.fieldName) - const bIsSelected = visibleFields.includes(b.fieldName) + const aIdx = rsvpFormFields.findIndex(f => f.field === a.fieldName) + const bIdx = rsvpFormFields.findIndex(f => f.field === b.fieldName) + const aIsSelected = aIdx !== -1 + const bIsSelected = bIdx !== -1 if (aIsSelected && !bIsSelected) return -1 if (!aIsSelected && bIsSelected) return 1 - - if (aIsSelected && bIsSelected) { - return visibleFields.indexOf(a.fieldName) - visibleFields.indexOf(b.fieldName) - } - + if (aIsSelected && bIsSelected) return aIdx - bIdx return a.originalIndex - b.originalIndex }) @@ -171,35 +164,55 @@ export const RegistrationFieldsComponent: React.FC { if (mandatedFieldNames.length === 0) return - const missingVisibleMandated = mandatedFieldNames.filter((f) => !visibleFields.includes(f)) - if (missingVisibleMandated.length > 0) { - const newVisibleFields = [...visibleFields, ...missingVisibleMandated] - onVisibleFieldsChange(newVisibleFields) - - const missingRequiredMandated = mandatedFieldNames.filter((f) => !requiredFields.includes(f)) - if (missingRequiredMandated.length > 0) { - const newRequiredFields = newVisibleFields.filter((f) => - requiredFields.includes(f) || missingRequiredMandated.includes(f) - ) - onRequiredFieldsChange(newRequiredFields) - } + const missingVisible = mandatedFieldNames.filter(f => !rsvpFormFields.some(e => e.field === f)) + if (missingVisible.length > 0) { + const additions = missingVisible.map(f => ({ field: f, required: true as const })) + const updated = [ + ...rsvpFormFields.map(f => mandatedFieldNames.includes(f.field) ? { ...f, required: true as const } : f), + ...additions, + ] + onRsvpFormFieldsChange(updated) } else { - const missingRequiredMandated = mandatedFieldNames.filter((f) => !requiredFields.includes(f)) - if (missingRequiredMandated.length > 0) { - const newRequiredFields = visibleFields.filter((f) => - requiredFields.includes(f) || missingRequiredMandated.includes(f) + const needsRequiredUpdate = mandatedFieldNames.some(f => { + const entry = rsvpFormFields.find(e => e.field === f) + return entry && !entry.required + }) + if (needsRequiredUpdate) { + onRsvpFormFieldsChange( + rsvpFormFields.map(f => mandatedFieldNames.includes(f.field) ? { ...f, required: true } : f) ) - onRequiredFieldsChange(newRequiredFields) } } - }, [mandatedFieldNames, visibleFields, requiredFields, onVisibleFieldsChange, onRequiredFieldsChange]) + }, [mandatedFieldNames, rsvpFormFields, onRsvpFormFieldsChange]) + + // Updates internal option selections and emits updated rsvpFormFields + const applyOptionPatch = useCallback((patch: Record) => { + const next = { ...rsvpOptionSelections } + for (const [key, val] of Object.entries(patch)) { + if (val === null || val === undefined) delete next[key] + else next[key] = val + } + setRsvpOptionSelections(next) + onRsvpFormFieldsChange( + rsvpFormFields.map(f => { + const sel = next[f.field] + const entry: RsvpFieldEntry = { field: f.field } + if (f.required) entry.required = f.required + if (sel?.disabledValues.length) { + entry.options = sel.order.filter(v => !sel.disabledValues.includes(v)) + } + return entry + }) + ) + }, [rsvpOptionSelections, rsvpFormFields, onRsvpFormFieldsChange]) const handleDragStart = (e: React.DragEvent, displayIndex: number) => { const field = sortedDisplayFields[displayIndex] - if (!visibleFields.includes(field.fieldName)) return + if (!rsvpFormFields.some(f => f.field === field.fieldName)) return setExpandedOptions(new Set()) setOptionDrag(null) @@ -212,7 +225,7 @@ export const RegistrationFieldsComponent: React.FC { e.preventDefault() const field = sortedDisplayFields[displayIndex] - if (!visibleFields.includes(field.fieldName)) return + if (!rsvpFormFields.some(f => f.field === field.fieldName)) return e.dataTransfer.dropEffect = 'move' if (draggedIndex !== null && draggedIndex !== displayIndex) { @@ -236,24 +249,20 @@ export const RegistrationFieldsComponent: React.FC f.field === draggedField.fieldName) + const toIdx = rsvpFormFields.findIndex(f => f.field === dropField.fieldName) + + if (fromIdx === -1 || toIdx === -1) { setDraggedIndex(null) setDragOverIndex(null) return } - const newVisibleFields = [...visibleFields] - const draggedVisibleIdx = newVisibleFields.indexOf(draggedField.fieldName) - const dropVisibleIdx = newVisibleFields.indexOf(dropField.fieldName) - - const [removed] = newVisibleFields.splice(draggedVisibleIdx, 1) - newVisibleFields.splice(dropVisibleIdx, 0, removed) - - onVisibleFieldsChange(newVisibleFields) - - const newRequiredFields = newVisibleFields.filter((f) => requiredFields.includes(f)) - onRequiredFieldsChange(newRequiredFields) + const reordered = [...rsvpFormFields] + const [moved] = reordered.splice(fromIdx, 1) + reordered.splice(toIdx, 0, moved) + onRsvpFormFieldsChange(reordered) setDraggedIndex(null) setDragOverIndex(null) } @@ -265,37 +274,42 @@ export const RegistrationFieldsComponent: React.FC { if (checked) { - const newVisibleFields = [...visibleFields, fieldName] - onVisibleFieldsChange(newVisibleFields) - const newRequiredFields = newVisibleFields.filter((f) => requiredFields.includes(f)) - if (newRequiredFields.length !== requiredFields.length || - !newRequiredFields.every((f, i) => f === requiredFields[i])) { - onRequiredFieldsChange(newRequiredFields) - } + const entry: RsvpFieldEntry = { field: fieldName } const def = getFieldDef(fieldName) if (fieldSourceMode === 'scope' && def && isSelectableField(def)) { - applyOptionPatch({ [fieldName]: defaultOptionSelectionFromField(def) }) + const sel = defaultOptionSelectionFromField(def) + setRsvpOptionSelections(prev => ({ ...prev, [fieldName]: sel })) } + onRsvpFormFieldsChange([...rsvpFormFields, entry]) } else { - onVisibleFieldsChange(visibleFields.filter((f) => f !== fieldName)) - onRequiredFieldsChange(requiredFields.filter((f) => f !== fieldName)) - applyOptionPatch({ [fieldName]: null }) + setRsvpOptionSelections(prev => { + const next = { ...prev } + delete next[fieldName] + return next + }) setExpandedOptions(prev => { const next = new Set(prev); next.delete(fieldName); return next }) + onRsvpFormFieldsChange(rsvpFormFields.filter(f => f.field !== fieldName)) } } const handleRequiredToggle = (fieldName: string, checked: boolean) => { if (checked) { - const newVisible = visibleFields.includes(fieldName) ? visibleFields : [...visibleFields, fieldName] - onVisibleFieldsChange(newVisible) - const newRequired = newVisible.filter((f) => requiredFields.includes(f) || f === fieldName) - onRequiredFieldsChange(newRequired) + const alreadyVisible = rsvpFormFields.some(f => f.field === fieldName) const def = getFieldDef(fieldName) if (fieldSourceMode === 'scope' && def && isSelectableField(def) && !rsvpOptionSelections[fieldName]) { - applyOptionPatch({ [fieldName]: defaultOptionSelectionFromField(def) }) + setRsvpOptionSelections(prev => ({ ...prev, [fieldName]: defaultOptionSelectionFromField(def) })) + } + if (alreadyVisible) { + onRsvpFormFieldsChange(rsvpFormFields.map(f => f.field === fieldName ? { ...f, required: true } : f)) + } else { + onRsvpFormFieldsChange([...rsvpFormFields, { field: fieldName, required: true }]) } } else { - onRequiredFieldsChange(requiredFields.filter((f) => f !== fieldName)) + onRsvpFormFieldsChange(rsvpFormFields.map(f => { + if (f.field !== fieldName) return f + const { required: _, ...rest } = f + return rest + })) } } @@ -428,8 +442,8 @@ export const RegistrationFieldsComponent: React.FC {sortedDisplayFields.map((displayField, displayIndex) => { const { fieldName, label, isMandated } = displayField - const isVisible = visibleFields.includes(fieldName) - const isRequired = requiredFields.includes(fieldName) + const isVisible = rsvpFormFields.some(f => f.field === fieldName) + const isRequired = rsvpFormFields.some(f => f.field === fieldName && f.required === true) const isDragging = draggedIndex === displayIndex const isDragOver = dragOverIndex === displayIndex const canDrag = isVisible diff --git a/web-src/src/types/domain.ts b/web-src/src/types/domain.ts index d7f4873..c681013 100644 --- a/web-src/src/types/domain.ts +++ b/web-src/src/types/domain.ts @@ -264,7 +264,7 @@ export interface EventApiResponse { localizations?: Record venue?: Record agenda?: AgendaDataItem[] // Localizable array - rsvpFormFields?: Record + rsvpFormFields?: { fields: { field: string; required?: boolean; options?: string[] }[] } rsvpDescription?: string // Localizable video?: VideoData registration?: RegistrationData @@ -614,7 +614,7 @@ export interface EventFormData { registration?: RegistrationData marketoFormUrl?: string marketoIntegration?: MarketoIntegrationData - rsvpFormFields?: Record + rsvpFormFields?: { field: string; required?: boolean; options?: string[] }[] visibleRsvpFields?: string[] requiredRsvpFields?: string[] /** diff --git a/web-src/src/utils/eventFormMappers.ts b/web-src/src/utils/eventFormMappers.ts index b873fac..c59f045 100644 --- a/web-src/src/utils/eventFormMappers.ts +++ b/web-src/src/utils/eventFormMappers.ts @@ -157,9 +157,7 @@ export function mapApiResponseToFormData(event: EventApiResponse, locale: string // Only populate marketoFormUrl from formData when type is Marketo. // When type is ESP, formData is "v1" (placeholder token for rsvpFormFields) — do not show in Marketo input. marketoFormUrl: event.registration?.type === 'Marketo' ? (event.registration.formData || '') : '', - visibleRsvpFields: event.rsvpFormFields?.visible || [], - requiredRsvpFields: event.rsvpFormFields?.required || [], - rsvpOptionSelections: {}, + rsvpFormFields: event.rsvpFormFields?.fields ?? [], images: event.images || [], profiles: mapSpeakersToProfiles(event.speakers || [], locale), communityForumUrl: cta?.url || '',