diff --git a/.claude/commands/sync.md b/.claude/commands/sync.md new file mode 100644 index 0000000..3a82c88 --- /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-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" + - 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/.claude/settings.json b/.claude/settings.json index 7e757c3..6d9a27c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -43,5 +43,41 @@ "Bash(* --force)", "Bash(sudo *)" ] + }, + "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\"" + } + ] + } + ] + }, + "mcpServers": { + "pim": { + "command": "npx", + "args": [ + "-y", + "@pim/mcp-server@0.1.0" + ], + "env": { + "PIM_API_URL": "https://d1ygncl0yqo6sv.cloudfront.net" + } + } } } 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..64ed1cb --- /dev/null +++ b/.claude/temp-specs/scope-configs-fe-integration-guide.md @@ -0,0 +1,583 @@ +# Scope Configs — FE Integration Guide + +## Overview + +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. + +Custom attributes are a config type (`type: 'custom-attributes'`), not a separate entity. + +--- + +## API Endpoints + +### Reading Configs (most common FE use case) + +``` +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 +``` + +### 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": [ + { + "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, + "displayAs": "dropdown", + "options": [], + "rules": "", + "default": "" + }, + { + "field": "email", + "label": "Email", + "placeholder": "Email", + "type": "email", + "required": true, + "displayAs": "dropdown", + "options": [], + "rules": "", + "default": "" + }, + { + "field": "jobTitle", + "label": "Job title", + "type": "select", + "required": true, + "displayAs": "dropdown", + "options": [ + { "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": "" + }, + { + "field": "productsOfInterest", + "label": "Products of interest", + "type": "multi-select", + "required": false, + "displayAs": "checkbox", + "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": "" + } + ], + "localizations": { + "fr-FR": { + "rsvpFormFields": [ + { "field": "firstName", "label": "Prénom", "placeholder": "Prénom" }, + { "field": "email", "label": "Courriel", "placeholder": "Courriel" }, + { "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" } + ] + } + } + }, + { + "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": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", + "creationTime": 1712345800000, + "modificationTime": 1712345800000, + "attributes": [ + { + "attributeId": "52ff35ec-746b-4d3d-9835-5cf5eac0910b", + "name": "primaryProductName", + "inputType": "single-select", + "enabled": true, + "values": [ + { "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 } + ] + }, + { + "attributeId": "f75144b3-74a7-4a66-b2f5-2987d154546f", + "name": "promotionalContent", + "inputType": "multi-select", + "enabled": true, + "values": [ + { "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 } + ] + }, + { + "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": [] + } + ] + } + ], + "count": 3, + "nextPageToken": null +} +``` + +### GET /v1/events/{eventId}/configs?type=rsvp — Filtered + +```json +{ + "configs": [ + { + "configId": "5bff274f-4f58-419e-8729-9096fac8b737", + "type": "rsvp", + "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", + "rsvpFormFields": [ "...same as above..." ], + "localizations": { "...same as above..." } + } + ], + "count": 1, + "nextPageToken": null +} +``` + +### POST /v1/scopes/{scopeId}/configs — Create config + +**Request:** + +```json +{ + "type": "rsvp", + "rsvpFormFields": [ + { "field": "firstName", "label": "First name", "type": "text", "required": true } + ] +} +``` + +**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", "type": "text", "required": true } + ] +} +``` + +### POST — Create custom-attributes config + +**Request:** + +```json +{ + "type": "custom-attributes", + "attributes": [ + { + "name": "targetAudience", + "inputType": "single-select", + "enabled": true, + "values": [ + { "value": "Enterprise" }, + { "value": "SMB" }, + { "value": "Education" } + ] + } + ] +} +``` + +**Response (201):** + +```json +{ + "configId": "e5f6a7b8-1234-5678-9abc-def012345678", + "type": "custom-attributes", + "scopeId": "de8013c1-f1d3-4f23-a62e-cdc80abf3093", + "creationTime": 1712350000000, + "modificationTime": 1712350000000, + "attributes": [ + { + "name": "targetAudience", + "inputType": "single-select", + "enabled": true, + "values": [ + { "value": "Enterprise" }, + { "value": "SMB" }, + { "value": "Education" } + ] + } + ] +} +``` + +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 + +**Request/Response:** Same shape as create, returns updated config with new `modificationTime`. + +### DELETE /v1/scopes/{scopeId}/configs/{configId} + +**Response: 204 No Content** (empty body) + +--- + +## Error Responses + +```json +{ "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"` | + + +--- + +## Integration Patterns + +### 1. Loading RSVP Form Fields + +```javascript +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 || []; + + 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" + // field.required → true/false + // 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 + // }); + }); +} +``` + +### 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); + // 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: localizedOptions + }; +}); +``` + +### 3. Loading Locales + +```javascript +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" } + + const localeUrlCodes = localesConfig.localeUrlCodes; + // { "en-US": "", "fr-FR": "fr" } +} +``` + +### 4. Loading Custom Attributes + +```javascript +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 }] + + switch (attr.inputType) { + 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 + } +}); +``` + +### 5. Saving Custom Attribute Values on Event + +```javascript +const customAttributes = []; + +// Single-select: one entry +customAttributes.push({ + attributeId: "52ff35ec-...", + attribute: "primaryProductName", + valueId: "a1b2c3d4-0001", + value: "Photoshop", + displayOrder: 0 +}); + +// 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-0003", value: "Video", displayOrder: 2 } +); + +// Text +customAttributes.push({ + attributeId: "c3d4e5f6-...", + attribute: "splashPageKey", + value: "summit-2026-splash" +}); + +// Boolean +customAttributes.push({ + attributeId: "d4e5f6a7-...", + attribute: "isVipEvent", + value: "true" +}); + +// Save on event +await fetch(`/v1/events/${eventId}`, { + method: 'PUT', + headers, + body: JSON.stringify({ ...eventData, customAttributes }) +}); +``` + +### 6. Loading All Configs at Once + +```javascript +// 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: allConfigs } = await allRes.json(); +const { configs: rsvpConfigs } = await rsvpRes.json(); + +// 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'); +``` + +--- + +## Hierarchy 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 + +To override an inherited config at a child scope (e.g., team wants to disable some custom attributes from the org): + +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` + +```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 + +### 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 | + + +### 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 | + + +--- + +## Notes + +- 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 +- **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/.gitignore b/.gitignore index 6a02b29..b3f05da 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ coverage # logs folder for aio-run-detached logs + +# PIM local state +.pim/ +.pim.json diff --git a/CLAUDE.md b/CLAUDE.md index 6650c79..2183128 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,3 +110,93 @@ 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-follow-9f4cd4`. +PIM server: `https://d1ygncl0yqo6sv.cloudfront.net` + +### 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. + +### PIM MCP Tools (Preferred) + +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 | +| `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 | + +### 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-follow-9f4cd4 --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-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..." +``` + +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-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 + + 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 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/components/shared/DataTable.tsx b/web-src/src/components/shared/DataTable.tsx index b221303..9b12166 100644 --- a/web-src/src/components/shared/DataTable.tsx +++ b/web-src/src/components/shared/DataTable.tsx @@ -56,6 +56,7 @@ interface DataTableProps { renderExpandedContent?: (item: T) => React.ReactNode expandedKeys?: Set onToggleExpand?: (key: string) => void + isRowExpandable?: (item: T) => boolean testIds?: DataTableTestIds } @@ -285,6 +286,7 @@ export function DataTable>({ renderExpandedContent, expandedKeys, onToggleExpand, + isRowExpandable, testIds }: DataTableProps): React.ReactElement { const isExpandable = !!renderExpandedContent @@ -564,20 +566,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 dc04039..f38ab83 100644 --- a/web-src/src/components/shared/ResourceDashboardLayout.tsx +++ b/web-src/src/components/shared/ResourceDashboardLayout.tsx @@ -58,6 +58,7 @@ interface ResourceDashboardLayoutProps { renderExpandedContent?: (item: T) => React.ReactNode expandedKeys?: Set onToggleExpand?: (key: string) => void + isRowExpandable?: (item: T) => boolean /** Notified when the debounced search query changes (including cleared). */ onDebouncedQueryChange?: (query: string) => void @@ -92,6 +93,7 @@ export function ResourceDashboardLayout>({ renderExpandedContent, expandedKeys, onToggleExpand, + isRowExpandable, onDebouncedQueryChange, searchLoading = false, }: ResourceDashboardLayoutProps): React.ReactElement { @@ -270,6 +272,7 @@ export function ResourceDashboardLayout>({ renderExpandedContent={renderExpandedContent} expandedKeys={expandedKeys} onToggleExpand={onToggleExpand} + isRowExpandable={isRowExpandable} testIds={dataTableTestIds} emptyState={ debouncedQuery ? ( 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..4a251e1 100644 --- a/web-src/src/hooks/useEventFormSave.ts +++ b/web-src/src/hooks/useEventFormSave.ts @@ -311,12 +311,10 @@ export function useEventFormSave() { } } - // RSVP form fields - if (mergedData.visibleRsvpFields || mergedData.requiredRsvpFields) { - payload.rsvpFormFields = { - visible: mergedData.visibleRsvpFields || [], - required: mergedData.requiredRsvpFields || [] - } + // 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.rsvpFormFields?.length) { + payload.rsvpFormFields = { fields: mergedData.rsvpFormFields } } // Ensure seriesId is set @@ -504,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/ConfigManagement/ConfigManagement.tsx b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx new file mode 100644 index 0000000..b0543c3 --- /dev/null +++ b/web-src/src/pages/ConfigManagement/ConfigManagement.tsx @@ -0,0 +1,2392 @@ +/** + * 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 ChevronRight from '@react-spectrum/s2/icons/ChevronRight' +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, + CustomAttributesScopeConfig, + RsvpFormField, + RsvpFormFieldLocaleOverride, + RsvpOption, + CustomAttributeConfig, + CustomAttributeValue, + CustomAttributeInputType, + RsvpFieldType, + RsvpDisplayAs, +} 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 { + 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' }, +] + +function getDisplayAsOptions(type: RsvpFieldType): { key: RsvpDisplayAs; label: string }[] { + if (type === 'select') return [ + { key: '' as RsvpDisplayAs, label: 'Default' }, + { key: 'dropdown', label: 'Dropdown' }, + { key: 'radio', label: 'Radio' }, + ] + if (type === 'multi-select') return [ + { key: '' as RsvpDisplayAs, label: 'Default' }, + { key: 'dropdown', label: 'Dropdown' }, + { key: 'checkbox', label: 'Checkbox' }, + ] + return [] +} + +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 [activeTab, setActiveTab] = useState('rsvp') + + // ============================================================================ + // RSVP DIALOG STATE + // ============================================================================ + + const [isRsvpFormOpen, setIsRsvpFormOpen] = useState(false) + const [editingRsvpConfig, setEditingRsvpConfig] = useState(null) + const [rsvpFormFields, setRsvpFormFields] = useState([]) + const [rsvpLocalizations, setRsvpLocalizations] = useState>({}) + 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()) + + // ============================================================================ + // 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 [attrFormLabel, setAttrFormLabel] = useState('') + const [attrFormInputType, setAttrFormInputType] = useState('text') + const [attrFormValues, setAttrFormValues] = useState([]) + const [attrFormEnabled, setAttrFormEnabled] = useState(true) + const [attrToDelete, setAttrToDelete] = useState(null) + + // 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) + + // ============================================================================ + // 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] + ) + const customAttrsConfig = useMemo( + () => configs.find((c): c is CustomAttributesScopeConfig => c.type === 'customAttributes') || null, + [configs] + ) + const customAttributes = useMemo( + () => customAttrsConfig?.attributes || [], + [customAttrsConfig] + ) + // 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]) + + useEffect(() => { loadScopes() }, [loadScopes]) + useEffect(() => { loadConfigs() }, [loadConfigs]) + + // Clear state on scope change + 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 + if (!scopesForPicker.some(s => s.scopeId === selectedScopeId)) { + setSelectedScopeId(null) + } + }, [selectedScopeId, scopesForPicker]) + + // ============================================================================ + // RSVP CONFIG CRUD + // ============================================================================ + + const openRsvpCreate = useCallback(() => { + setEditingRsvpConfig(null) + setRsvpFormFields([createEmptyRsvpField()]) + setRsvpLocalizations({}) + setExpandedRsvpDialogFields(new Set([0])) + setIsRsvpFormOpen(true) + }, []) + + const openRsvpEdit = useCallback((config: RsvpScopeConfig) => { + setEditingRsvpConfig(config) + setRsvpFormFields([...config.rsvpFormFields]) + setRsvpLocalizations(config.localizations ? JSON.parse(JSON.stringify(config.localizations)) : {}) + setExpandedRsvpDialogFields(new Set([0])) + 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()) + 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, + 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, + 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, 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) + 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('') + setAttrFormLabel('') + setAttrFormInputType('text') + setAttrFormValues([]) + setAttrFormEnabled(true) + setIsAttrFormOpen(true) + }, []) + + 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) + setIsAttrFormOpen(true) + }, []) + + const handleSaveAttr = useCallback(async () => { + if (!selectedScopeId || !attrFormName.trim()) { + toast.error('Name is required') + return + } + + const newAttr: CustomAttributeConfig = { + attributeId: editingAttr?.attributeId || generateUUID(), + name: attrFormName.trim(), + label: attrFormLabel.trim() || undefined, + inputType: attrFormInputType, + enabled: attrFormEnabled, + values: attrFormValues + .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, + })), + } + + setIsSaving(true) + try { + 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 config was modified by someone else. Refresh and try again.' + : `Failed to ${editingAttr ? 'update' : 'create'} custom attribute`) + return + } + } else { + const result = await apiService.createConfig(selectedScopeId, { + type: 'customAttributes', + attributes: [newAttr], + }) + if ('error' in result) { + toast.error('Failed to create custom attribute') + return + } + } + toast.success(`Custom attribute ${editingAttr ? 'updated' : 'created'}`) + setIsAttrFormOpen(false) + setIsSaving(false) + await loadConfigs() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save custom attribute') + } finally { + setIsSaving(false) + } + }, [selectedScopeId, attrFormName, attrFormLabel, attrFormInputType, attrFormValues, attrFormEnabled, editingAttr, customAttrsConfig, apiService, toast, loadConfigs]) + + const handleDeleteAttr = useCallback(async (attr: CustomAttributeConfig) => { + if (!selectedScopeId || !customAttrsConfig) return + setIsSaving(true) + try { + 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 loadConfigs() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete custom attribute') + } finally { + setIsSaving(false) + } + }, [apiService, selectedScopeId, customAttrsConfig, toast, loadConfigs]) + + // ============================================================================ + // 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 TABLE COLUMNS (for display) + // ============================================================================ + + const rsvpFieldsForTable = useMemo(() => { + if (!rsvpConfig) return [] + return rsvpConfig.rsvpFormFields.map((f, i) => ({ + ...f, + _key: `${f.field}-${i}`, + })) + }, [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, + 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} +
+ ), + }, + { + 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.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 ( +
+ {isSelectType && ( +
+ {/* 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 && ( +
+ 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.map(o => o.label || o.value).join(', ')}} +
+ ) + })} +
+
+ )} + {!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]) + + // ============================================================================ + // CUSTOM ATTRIBUTES TABLE + // ============================================================================ + + const isOwnAttrsConfig = customAttrsConfig?.scopeId === selectedScopeId + + 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', + width: 100, + sortable: true, + render: (item: CustomAttributeConfig) => ( +
+ + {item.enabled ? 'Yes' : 'No'} + +
+ ), + }, + { + key: 'inputType', + name: 'INPUT TYPE', + width: 140, + sortable: true, + render: (item: CustomAttributeConfig) => ( +
+ {item.inputType} +
+ ), + }, + { + key: 'values', + name: 'VALUES', + width: 100, + sortable: false, + render: (item: CustomAttributeConfig) => ( + {item.values.length > 0 ? `${item.values.length} values` : '-'} + ), + }, + { + key: 'scopeId', + name: 'SCOPE', + width: 120, + sortable: false, + render: () => { + const configScopeId = customAttrsConfig?.scopeId + const scope = configScopeId ? scopes.find(s => s.scopeId === configScopeId) : null + return scope ? ( + {scope.name} + ) : configScopeId ? ( + + {configScopeId.substring(0, 8)}... + + ) : ( + - + ) + }, + }, + { + key: 'actions', + name: 'ACTIONS', + width: 100, + sortable: false, + render: (item: CustomAttributeConfig) => ( +
+ {canWriteConfig && isOwnAttrsConfig && ( + openAttrEdit(item)}> + + + )} + {canDeleteConfig && isOwnAttrsConfig && ( + setAttrToDelete(item)}> + + + )} +
+ ), + }, + ], [scopes, customAttrsConfig, isOwnAttrsConfig, canWriteConfig, canDeleteConfig, openAttrEdit]) + + const renderAttrExpandedContent = useCallback((item: CustomAttributeConfig) => { + 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 ( +
+
+ {/* 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 + // ============================================================================ + + const { loadingOverlayVisible, savingOverlayVisible } = useMemo(() => { + const isBlockingDialogOpen = + isRsvpFormOpen || isLocalesFormOpen || isAttrFormOpen || + rsvpConfigToDelete != null || localesToDelete != null || attrToDelete != null + return { + loadingOverlayVisible: (isLoadingScopes || isLoadingConfigs) && !isSaving, + savingOverlayVisible: isSaving && !isBlockingDialogOpen, + } + }, [ + isRsvpFormOpen, isLocalesFormOpen, isAttrFormOpen, + rsvpConfigToDelete, localesToDelete, attrToDelete, + isLoadingScopes, isLoadingConfigs, + 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} + isRowExpandable={isRsvpFieldExpandable} + toolbarEnd={ + setActiveLocale(key === '' ? null : key as string)} + styles={style({ width: 220 })} + > + + Base (default) + + {availableLocales.map(l => ( + + {l.name} ({l.code}) + + ))} + + } + /> + ) : ( +
+ + 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={loadConfigs} + 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} + isRowExpandable={(item: CustomAttributeConfig) => item.values.length > 0} + /> +
+
+
+ ) : ( +
+ + Select a scope above to manage its configurations. + +
+ )} +
+ + {/* ══════════════════════════════════════════════════════════════════════ + 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 */} + +
+ + {({close}) => ( + <> + {editingRsvpConfig ? 'Edit RSVP Config' : 'Create RSVP Config'} + +
+ {/* Fields editor */} +
+
+ 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' + 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) */} +
+
+ {isCollapsible && ( + + )} + {field.field || `Field ${index + 1}`} +
+ {field.type} +
+ {field.required && ( +
+ Required +
+ )} +
+ {!(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 && ( +
+
+ setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], field: v } + return copy + })} + isRequired + /> + setDialogLocaleFieldValue(field.field, { label: v || undefined }) + : (v) => setRsvpFormFields(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], label: v } + return copy + }) + } + isRequired={!(activeLocale && editingRsvpConfig)} + /> + setDialogLocaleFieldValue(field.field, { placeholder: v || undefined }) + : (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})` : ''} + + {!(activeLocale && editingRsvpConfig) && ( + 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 })} + /> + 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 })} + /> + {!(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 && ( + + No options yet — click "Add Option" above. + + )} +
+
+ )} +
+ )} +
+ ) + })} +
+
+ +
+
+ + + + + + )} +
+ + + {/* 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'} + +
+ + + + Enabled + + { + 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.length > 0 ? ` (${attrFormValues.length})` : ''} + + setAttrFormValues(prev => [...prev, { + value: '', + label: '', + displayOrder: prev.length, + }])} + > + + Add Value + +
+
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 })} + /> + setAttrFormValues(prev => { + const copy = [...prev] + copy[index] = { ...copy[index], label: v } + return copy + })} + styles={style({ flexGrow: 1 })} + /> + setAttrFormValues(prev => + prev.filter((_, i) => i !== index).map((v, i) => ({ ...v, displayOrder: i })) + )} + > + + +
+ ))} + {attrFormValues.length === 0 && ( + + No values yet — click "Add Value" above. + + )} +
+
+ )} +
+
+ + + + + + )} +
+ + + {/* 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 */} + !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 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/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 new file mode 100644 index 0000000..2482dc8 --- /dev/null +++ b/web-src/src/pages/EventForm/CustomAttributesComponent.tsx @@ -0,0 +1,346 @@ +/* +* +*/ + +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))} + styles={style({ flexGrow: 1 })} + > + {getAvailableOptions(row.value).map(opt => ( + {opt.label || opt.value} + ))} + + removeRow(row.id)} + > + + +
+ ))} + + {!allOptionsUsed && ( + + )} +
+ ) +} + +// ============================================================================ +// CustomAttributesComponent +// ============================================================================ + +export const CustomAttributesComponent: React.FC = () => { + const { formData, updateFormData } = useEventFormComponent({ + componentId: 'customAttributes', + }) + + 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, 'customAttributes') + if (!('error' in result)) { + 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 }) + } + } 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))} + styles={style({ alignSelf: 'start' })} + > + {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.label || 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 a5d11d2..f5e9f59 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' @@ -54,6 +55,7 @@ import { useEventFormSave } from '../../hooks/useEventFormSave' import { useCustomDetailPagePath } from '../../hooks/useCustomDetailPagePath' import { COLORS, Z_INDEX, TYPOGRAPHY, SURFACES } from '../../styles/designSystem' import { ENVIRONMENTS, getCurrentEnvironment, getEspEnvParam } from '../../config/constants' +import { validateForPublish, PublishGuardResult } from '../../utils/publishGuard' // ============================================================================ // FORMAT SELECTION OVERLAY @@ -514,8 +516,10 @@ const EventFormInner: React.FC = ({ ims: _ims }) => { const prodPublishExtraRef = useRef | undefined>(undefined) const [prodPublishConfirmOpen, setProdPublishConfirmOpen] = useState(false) const [isCheckingUrl, setIsCheckingUrl] = useState(false) + const [publishGuardResult, setPublishGuardResult] = useState(null) const [sessionHasOpenForm, setSessionHasOpenForm] = useState(false) - + + // Show toast when saveError changes useEffect(() => { if (saveError && saveError !== lastErrorShownRef.current) { @@ -835,11 +839,18 @@ 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 await requestPublishAfterUrlResolved(extraPayload) - }, [checkUrlPatternBeforeSave, requestPublishAfterUrlResolved]) + }, [formData, hasVenue, checkUrlPatternBeforeSave, requestPublishAfterUrlResolved]) /** * Handle max step change from FormWizard @@ -965,6 +976,8 @@ const EventFormInner: React.FC = ({ ims: _ims }) => { )} + + ) @@ -1205,6 +1218,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/pages/EventForm/EventInfoComponent.tsx b/web-src/src/pages/EventForm/EventInfoComponent.tsx index 278342e..4776738 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, @@ -132,6 +130,8 @@ const EVENT_TITLE_MAX_LENGTH = 150 * startDateTime, endDateTime, timezone, communityForumUrl, secondaryLinkTitle, isPrivate */ export const EventInfoComponent: React.FC = () => { + const { activeGroup } = useGroup() + // ============================================================================ // CONTEXT INTEGRATION // ============================================================================ @@ -176,21 +176,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) { @@ -311,7 +336,7 @@ export const EventInfoComponent: React.FC = () => { selectedKey={locale || null} onSelectionChange={handleLanguageChange} > - {localeOptions.map((opt) => ( + {pickerLocaleOptions.map((opt) => ( {opt.label} ))} 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 fa77e24..c6012e7 100644 --- a/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx +++ b/web-src/src/pages/EventForm/RegistrationConfigComponent.tsx @@ -47,8 +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 rsvpFormFields = formData.rsvpFormFields || [] // ============================================================================ // LOCAL STATE @@ -100,12 +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 handleRsvpFormFieldsChange = (fields: { field: string; required?: boolean; options?: string[] }[]) => { + updateFormData({ rsvpFormFields: fields }) } const handleContactHostToggle = (value: boolean) => { @@ -252,14 +247,13 @@ export const RegistrationConfigComponent: React.FC = () => { {/* Registration Fields Configuration */}
diff --git a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx index 1a492ac..58402e8 100644 --- a/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx +++ b/web-src/src/pages/EventForm/RegistrationFieldsComponent.tsx @@ -1,100 +1,124 @@ -/* +/* * */ -import React, { useState, useEffect } from 'react' -import { TextField, RadioGroup, Radio, Text, Switch } from '@react-spectrum/s2' +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' import { COLORS, SURFACES } from '../../styles/designSystem' import OpenIn from '@react-spectrum/s2/icons/OpenIn' import Move from '@react-spectrum/s2/icons/Move' -import type { RsvpConfigField } from '../../types/attendee' -import { rsvpConfigUiLabel } from '../../utils/rsvpConfigLabels' - -interface RsvpConfig { - cloudType: string - config: RsvpConfigField[] | null -} +import ListBulleted from '@react-spectrum/s2/icons/ListBulleted' +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' + +type RsvpFieldEntry = { field: string; required?: boolean; options?: string[] } interface DisplayField { fieldName: string - displayLabel: string + label: string isMandated: boolean originalIndex: number } +export type RsvpFieldSourceMode = 'scope' | 'legacy' + interface RegistrationFieldsComponentProps { - cloudType: 'CreativeCloud' | 'ExperienceCloud' + isExperienceCloud: boolean eventType: 'InPerson' | 'Virtual' - visibleFields: string[] - requiredFields: string[] + cloudType: string + rsvpFormFields: RsvpFieldEntry[] registrationType: 'ESP' | 'Marketo' marketoFormUrl?: string - onVisibleFieldsChange: (fields: string[]) => void - onRequiredFieldsChange: (fields: string[]) => void + onRsvpFormFieldsChange: (fields: RsvpFieldEntry[]) => void onRegistrationTypeChange: (type: 'ESP' | 'Marketo') => void 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() -} - -const fetchRsvpFormConfigs = async (): Promise => { - const clouds = ['CreativeCloud', 'ExperienceCloud'] as const - return Promise.all( - 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}`) - return { cloudType: id, config: null } - } - const data = await response.json() - 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, + cloudType, + rsvpFormFields, registrationType, marketoFormUrl = '', - onVisibleFieldsChange, - onRequiredFieldsChange, + onRsvpFormFieldsChange, onRegistrationTypeChange, - onMarketoFormUrlChange + onMarketoFormUrlChange, }) => { - const [configs, setConfigs] = useState([]) + 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 configs on mount + const [optionDrag, setOptionDrag] = useState<{ fieldName: string; index: number } | null>(null) + const [optionDragOver, setOptionDragOver] = useState<{ fieldName: string; index: number } | null>(null) + + const [expandedOptions, setExpandedOptions] = useState>(new Set()) + + // 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 loadConfigs = async () => { + const scopeId = activeGroup?.scopeId + const cloudForLegacy = hasRsvpConfig(cloudType) ? cloudType : 'CreativeCloud' + + const loadFields = async () => { try { setLoading(true) - const fetchedConfigs = await fetchRsvpFormConfigs() - setConfigs(fetchedConfigs) + 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' + } + + setFields(nextFields) + setFieldSourceMode(mode) setError(null) } catch (err) { setError('Failed to load registration field configurations') @@ -104,84 +128,95 @@ 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) - - // Build display fields list with original order preserved + loadFields() + }, [activeGroup?.scopeId, cloudType]) + + 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, - displayLabel: rsvpConfigUiLabel(f, convertString), - isMandated: f.Required === 'x', + fieldName: f.field, + label: f.label, + isMandated: f.required, originalIndex: idx })) - // Sort fields: selected (visible) fields first, then unselected - // Within each group, maintain order based on visibleFields array (for selected) or original config order (for unselected) const sortedDisplayFields = [...allDisplayFields].sort((a, b) => { - 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 - - // 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 + if (aIsSelected && bIsSelected) return aIdx - bIdx return a.originalIndex - b.originalIndex }) - // Ensure mandated fields are always included in visible and required arrays + const getFieldDef = useCallback( + (fieldName: string) => 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]) + + // Ensure mandated fields are always visible and required 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) - } + 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 { - // 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) + 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.join(','), visibleFields, requiredFields, onVisibleFieldsChange, onRequiredFieldsChange]) - - // ============================================================================ - // DRAG AND DROP HANDLERS - // ============================================================================ + }, [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] - // Only allow dragging selected (visible) fields - if (!visibleFields.includes(field.fieldName)) return - + if (!rsvpFormFields.some(f => f.field === field.fieldName)) return + + setExpandedOptions(new Set()) + setOptionDrag(null) + setOptionDragOver(null) setDraggedIndex(displayIndex) e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', String(displayIndex)) @@ -190,9 +225,8 @@ export const RegistrationFieldsComponent: React.FC { e.preventDefault() const field = sortedDisplayFields[displayIndex] - // Only allow dropping on selected (visible) fields - if (!visibleFields.includes(field.fieldName)) return - + if (!rsvpFormFields.some(f => f.field === field.fieldName)) return + e.dataTransfer.dropEffect = 'move' if (draggedIndex !== null && draggedIndex !== displayIndex) { setDragOverIndex(displayIndex) @@ -205,7 +239,7 @@ export const RegistrationFieldsComponent: React.FC { e.preventDefault() - + if (draggedIndex === null || draggedIndex === dropDisplayIndex) { setDraggedIndex(null) setDragOverIndex(null) @@ -214,29 +248,21 @@ 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 } - // Reorder visibleFields array - 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) - - // Also reorder requiredFields to match the new visibleFields order - // Filter requiredFields to only include fields that are in newVisibleFields, maintaining the new order - 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) } @@ -246,53 +272,146 @@ 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 entry: RsvpFieldEntry = { field: fieldName } + const def = getFieldDef(fieldName) + if (fieldSourceMode === 'scope' && def && isSelectableField(def)) { + const sel = defaultOptionSelectionFromField(def) + setRsvpOptionSelections(prev => ({ ...prev, [fieldName]: sel })) } + onRsvpFormFieldsChange([...rsvpFormFields, entry]) } else { - // Remove from both visible and required - onVisibleFieldsChange(visibleFields.filter((f) => f !== fieldName)) - onRequiredFieldsChange(requiredFields.filter((f) => f !== fieldName)) + 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) { - // 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 alreadyVisible = rsvpFormFields.some(f => f.field === fieldName) + const def = getFieldDef(fieldName) + if (fieldSourceMode === 'scope' && def && isSelectableField(def) && !rsvpOptionSelections[fieldName]) { + 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 { + onRsvpFormFieldsChange(rsvpFormFields.map(f => { + if (f.field !== fieldName) return f + const { required: _, ...rest } = f + return rest + })) + } + } + + 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 { - // Remove from required only - onRequiredFieldsChange(requiredFields.filter((f) => f !== fieldName)) + 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.stopPropagation() + 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.stopPropagation() + 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 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} + + )} + + {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 - 4 columns now with drag handle */} -
MAKE IT REQUIRED - {/* Drag handle header - empty placeholder */} - +
- {/* Field rows */}
{sortedDisplayFields.map((displayField, displayIndex) => { - const { fieldName, displayLabel, isMandated } = displayField - const isVisible = visibleFields.includes(fieldName) - const isRequired = requiredFields.includes(fieldName) + const { fieldName, label, isMandated } = displayField + 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 + const fieldDef = getFieldDef(fieldName) + const showOptionEditor = fieldSourceMode === 'scope' && fieldDef && isSelectableField(fieldDef) + const optState = showOptionEditor ? getEffectiveOptionState(fieldName) : null + + const isExpanded = expandedOptions.has(fieldName) + const toggleExpanded = () => + setExpandedOptions(prev => { + const next = new Set(prev) + if (isExpanded) next.delete(fieldName) + else next.add(fieldName) + return next + }) + return (
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 + backgroundColor: isDragging + ? SURFACES.PILL_BG : isVisible ? SURFACES.CANVAS : 'transparent', - border: isDragOver - ? `2px solid ${SURFACES.SELECTED_RING}` + 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' }} > - - {displayLabel} - {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)} + onDragEnd={handleDragEnd} style={{ - display: 'flex', - justifyContent: 'center', + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr 40px 40px', + gap: '16px', alignItems: 'center', - cursor: canDrag ? 'grab' : 'default', - color: canDrag ? COLORS.GRAY_600 : SURFACES.BORDER, - opacity: canDrag ? 1 : 0.3 + padding: '12px 16px', }} > - + + {label} + {isMandated && ( + + (Always required) + + )} + + handleVisibleToggle(fieldName, checked)} + isDisabled={isMandated} + > + Appears on form + + handleRequiredToggle(fieldName, checked)} + isDisabled={!isVisible || isMandated} + > + Required field + + {showOptionEditor && optState && isVisible ? ( + + + + ) : null} +
+
+ + {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 + +
+
+
+ ) + })} +
+
+ )}
) })} @@ -457,8 +658,7 @@ export const RegistrationFieldsComponent: React.FC @@ -489,4 +689,3 @@ export const RegistrationFieldsComponent: React.FC ) } - 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/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 33b1002..2341f60 100644 --- a/web-src/src/services/api.ts +++ b/web-src/src/services/api.ts @@ -42,6 +42,12 @@ import type { PermissionsListResponse, ScopeType, } from '../types/rbacApi' +import type { + ConfigType, + ScopeConfig, + ConfigCreateBody, + ConfigUpdateBody, +} from '../types/configApi' // ============================================================================ // TYPES @@ -2342,6 +2348,85 @@ 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' + }) + } + + // --- 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' + }) + } } // ============================================================================ @@ -2501,6 +2586,14 @@ 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), + 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), + // === MUTATIONS (with cache invalidation) === // Series Mutations @@ -2615,6 +2708,28 @@ 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 + }, + // === UTILITY METHODS === /** diff --git a/web-src/src/types/configApi.ts b/web-src/src/types/configApi.ts new file mode 100644 index 0000000..c4114e4 --- /dev/null +++ b/web-src/src/types/configApi.ts @@ -0,0 +1,141 @@ +/** + * Scope Configs & Custom Attributes API type definitions + * + * Types matching the ESP scope config endpoints. + * Configs are scope-inherited (org -> team) and support three types: + * rsvp, locales, and customAttributes. + */ + +// ============================================================================ +// Enums & Primitives +// ============================================================================ + +export type ConfigType = 'rsvp' | 'locales' | 'customAttributes' + +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 +// ============================================================================ + +/** A single option in a select/multi-select RSVP field. + * `value` is the locale-independent DB key; `label` is the display text shown to users. */ +export interface RsvpOption { + value: string + label: string +} + +export interface RsvpFormField { + field: string + label: string + placeholder: string + type: RsvpFieldType + required: boolean + options: RsvpOption[] + rules: string + default: string + displayAs: RsvpDisplayAs +} + +/** Partial RSVP field for localization overrides (only translatable properties). + * Option overrides match base options by `value` and translate `label` only. */ +export interface RsvpFormFieldLocaleOverride { + field: string + label?: string + placeholder?: string + options?: RsvpOption[] +} + +// ============================================================================ +// Scope Config Models +// ============================================================================ + +interface ScopeConfigBase { + configId: string + type: ConfigType + scopeId: string + creationTime: number + modificationTime: number +} + +export interface RsvpScopeConfig extends ScopeConfigBase { + type: 'rsvp' + rsvpFormFields: RsvpFormField[] + localizations: Record +} + +export interface LocalesScopeConfig extends ScopeConfigBase { + type: 'locales' + localeNames: Record + localeUrlCodes: Record +} + +export interface CustomAttributesScopeConfig extends ScopeConfigBase { + type: 'customAttributes' + attributes: CustomAttributeConfig[] +} + +export type ScopeConfig = RsvpScopeConfig | LocalesScopeConfig | CustomAttributesScopeConfig + +// ============================================================================ +// Custom Attribute Models +// ============================================================================ + +export interface CustomAttributeValue { + valueId?: string + value: string + label: string + displayOrder: number +} + +export interface CustomAttributeConfig { + attributeId: string + label?: string + name: string + inputType: CustomAttributeInputType + enabled: boolean + isRequired?: boolean + values: CustomAttributeValue[] +} + +// ============================================================================ +// Request Bodies +// ============================================================================ + +export interface RsvpConfigCreateBody { + type: 'rsvp' + rsvpFormFields: RsvpFormField[] + localizations?: Record +} + +export interface LocalesConfigCreateBody { + type: 'locales' + localeNames: Record + localeUrlCodes: Record +} + +export interface CustomAttributesConfigCreateBody { + type: 'customAttributes' + attributes: CustomAttributeConfig[] +} + +export type ConfigCreateBody = + | RsvpConfigCreateBody + | LocalesConfigCreateBody + | CustomAttributesConfigCreateBody + +export type ConfigUpdateBody = ScopeConfig + +// ============================================================================ +// Response Envelopes +// ============================================================================ + +export interface ConfigListResponse { + configs: ScopeConfig[] + count: number + nextPageToken?: string | null +} diff --git a/web-src/src/types/domain.ts b/web-src/src/types/domain.ts index 5a05e24..502864e 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 @@ -557,6 +557,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 @@ -607,9 +615,14 @@ export interface EventFormData { registration?: RegistrationData marketoFormUrl?: string marketoIntegration?: MarketoIntegrationData - rsvpFormFields?: Record + rsvpFormFields?: { field: string; required?: boolean; options?: string[] }[] 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[] @@ -635,10 +648,24 @@ export interface EventFormData { localizations?: Record localizationOverrides?: Record metadata?: Record + customAttributes?: EventCustomAttributeValue[] + + // Transient fields (not submitted to API, used for cross-component validation) + _customAttributeConfigs?: import('./configApi').CustomAttributeConfig[] + /** UI-only: user explicitly chose a catalogue option (including "No …") per metadata field key */ metadataFieldAcknowledged?: Record } +export interface EventCustomAttributeValue { + attributeId: string + attribute: string + valueId?: string + value: string + displayOrder?: number + label?: string +} + // Agenda Item export interface AgendaItem { id: string 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' diff --git a/web-src/src/utils/dataFilters.ts b/web-src/src/utils/dataFilters.ts index 1edea2f..b91ad86 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..c59f045 100644 --- a/web-src/src/utils/eventFormMappers.ts +++ b/web-src/src/utils/eventFormMappers.ts @@ -157,8 +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 || [], + rsvpFormFields: event.rsvpFormFields?.fields ?? [], images: event.images || [], profiles: mapSpeakersToProfiles(event.speakers || [], locale), communityForumUrl: cta?.url || '', @@ -178,5 +177,6 @@ export function mapApiResponseToFormData(event: EventApiResponse, locale: string }), marketoIntegration: event.marketoIntegration, video: event.video, + customAttributes: event.customAttributes || [], } } 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, + } +} diff --git a/web-src/src/utils/rsvpFieldDefinitions.ts b/web-src/src/utils/rsvpFieldDefinitions.ts new file mode 100644 index 0000000..999a8e7 --- /dev/null +++ b/web-src/src/utils/rsvpFieldDefinitions.ts @@ -0,0 +1,96 @@ +/* +* +*/ + +import type { RsvpFormField, RsvpFieldType } from '../types/configApi' +import type { RsvpConfigField } from '../types/attendee' +import type { RsvpFieldOptionSelectionState } from '../types/domain' +import { rsvpConfigUiLabel } from './rsvpConfigLabels' + +const toUpperWords = (input: string): string => + input.replace(/([a-z])([A-Z])/g, '$1 $2').toUpperCase() + +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: rsvpConfigUiLabel(r, toUpperWords), + 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 +): field is RsvpFormField & { options: NonNullable } { + return (field.type === 'select' || field.type === 'multi-select') && (field.options?.length ?? 0) > 0 +}