diff --git a/plugins/app-builder/skills/appbuilder-cicd-pipeline/SKILL.md b/plugins/app-builder/skills/appbuilder-cicd-pipeline/SKILL.md index 049e00e5..dae22b30 100644 --- a/plugins/app-builder/skills/appbuilder-cicd-pipeline/SKILL.md +++ b/plugins/app-builder/skills/appbuilder-cicd-pipeline/SKILL.md @@ -8,8 +8,9 @@ description: >- user mentions CI/CD for App Builder, GitHub Actions for aio deploy, automated deployment pipelines, continuous integration, continuous delivery, deploy automation, multi-environment promotion, aio app add ci, or wants to automate their App Builder build and release process. - Also trigger when users mention deploy workflows, release pipelines, or GitHub secrets for - App Builder. + Also trigger when users mention deploy workflows, release pipelines, GitHub secrets for + App Builder, aio app deploy for AEM extensions, Extension + Manager approval, or automate deployment of an AEM UI extension. metadata: category: deployment-automation license: Apache-2.0 @@ -31,6 +32,7 @@ Set up CI/CD pipelines for Adobe App Builder projects — GitHub Actions (primar | Azure DevOps / GitLab CI / Jenkins | references/generic-pipeline-guide.md | | Secrets setup guide | references/secrets-management.md | | Debugging deploy failures | references/debugging.md | +| Content Hub extension deploy | references/contenthub-deploy.md | ## Fast Path (for clear requests) @@ -93,6 +95,7 @@ If user specifies Azure DevOps, GitLab CI, or Jenkins → use `references/generi - Use `references/secrets-management.md` for OAuth S2S credential extraction and GitHub secrets setup. - Use `references/debugging.md` for troubleshooting deploy failures, CI errors, and workspace promotion issues. - Use `references/checklist.md` for pre-merge CI readiness validation. +- Use `references/contenthub-deploy.md` for Content Hub extension deployment. - Use `assets/deploy-stage.yml`, `assets/deploy-prod.yml`, `assets/pr-test.yml` as workflow templates. - Use `assets/fetch-secrets.sh` to extract secret values from workspace configuration. - Official Adobe docs: [https://developer.adobe.com/app-builder/docs/guides/app_builder_guides/deployment/cicd-using-github-actions](https://developer.adobe.com/app-builder/docs/guides/app_builder_guides/deployment/cicd-using-github-actions) diff --git a/plugins/app-builder/skills/appbuilder-cicd-pipeline/references/contenthub-deploy.md b/plugins/app-builder/skills/appbuilder-cicd-pipeline/references/contenthub-deploy.md new file mode 100644 index 00000000..7e86adb6 --- /dev/null +++ b/plugins/app-builder/skills/appbuilder-cicd-pipeline/references/contenthub-deploy.md @@ -0,0 +1,142 @@ +# Adobe App Builder Extension Deployment + +Complete deployment workflow for a Content Hub extension (`aem/assets/contenthub/1`) from Stage through Production approval. + +--- + +## Prerequisites + +Before deploying: +1. `aio app run` has succeeded locally (extension visible with `ext=` URL param) +2. `allowedRepos` in `ExtensionRegistration.js` is populated with delivery repo IDs +3. The correct org and project are selected (`aio where` shows the right state) + +--- + +## Stage Deployment + +Stage is used for QA and stakeholder review before Production. + +```bash +# Switch workspace — unset CI/AIO_CLI_NO_TTY in subshell so credentials are downloaded +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w Stage --overwrite' 2>/dev/null + +# Deploy — capture output +aio app deploy 2>&1 | tee /tmp/aio-deploy.log +``` + +After deploy completes, **always** parse the CDN URL from the log — even if actions failed: + +```bash +CDN_URL=$(grep -Eo 'https://[^ ]+adobeio-static\.net[^ ]*' /tmp/aio-deploy.log | grep 'index\.html' | tail -1) +# If no index.html variant found, take any adobeio-static.net URL: +CDN_URL=${CDN_URL:-$(grep -Eo 'https://[^ ]+adobeio-static\.net[^ ]*' /tmp/aio-deploy.log | tail -1)} +``` + +Open Content Hub automatically with the deployed URL — **do not just print it**. Replace the local `ext=https://localhost:9080` with `ext=/index.html` and keep the Content Hub deep-link hash: + +```bash +open "https://experience.adobe.com/?devMode=true&ext=${CDN_URL}/index.html#/assets/contenthub/" +``` + +**Partial failure rule:** If web assets deployed but actions failed (Runtime not provisioned in the org, or 401 on action deploy), still open the CDN URL. The extension UI loads correctly. Actions will fail only when called. Do not block on action deployment errors. + +--- + +## Production Deployment + +```bash +# Switch to Production workspace — unset CI/AIO_CLI_NO_TTY in subshell +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w Production --overwrite' 2>/dev/null + +# Deploy to Production +aio app deploy +``` + +Production deployment must be followed by an approval step before the extension is visible to all users in the org. + +--- + +## Extension Manager Approval + +1. Open `https://experience.adobe.com/aem/extension-manager` +2. Find your extension by name +3. Click **Approve** +4. The extension becomes visible to all users in your org — no `ext=` URL parameter needed + +--- + +## Workspace Selection (`aio app use`) + +`aio app use` rewrites `.aio` and `.env` to point at the specified workspace. Always run it before `aio app deploy` when switching targets. + +```bash +# Check current workspace +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio where + +# Switch workspace — unset CI/AIO_CLI_NO_TTY in subshell so credentials are downloaded +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w Stage --overwrite' 2>/dev/null +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w Production --overwrite' 2>/dev/null +``` + +If `aio app use -w ` does not find the workspace by name, use explicit flags (also in clean subshell): +```bash +bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio app use --org --project --workspace Stage --no-input' +``` + +--- + +## Re-deploy After Code Changes + +```bash +# Rebuild and redeploy (from the project directory) +aio app deploy +``` + +For UI-only changes (no action changes), `aio app deploy` is still the right command — it rebuilds the web-src bundle and pushes to CDN. + +--- + +## Troubleshooting Deployments + +### Extension visible with `ext=` but not after approval + +The extension was approved in the wrong workspace. The `ext=` URL param bypasses workspace checks. + +```bash +aio where # confirm you're on Production +aio app use -w Production +aio app deploy +# Then re-approve in Extension Manager +``` + +### Extension invisible to some users + +The App Builder project includes extra Adobe services (AEM Assets Author API, Cloud Manager, etc.). Only users entitled to those services see the extension. + +**Fix:** Remove all non-required services from the App Builder project in Adobe Developer Console. Keep only Runtime. Redeploy and reapprove. + +### `aio app deploy` fails with auth error + +`.env` is missing or stale. Re-wire the workspace (unset CI in a subshell so credentials are downloaded): +```bash +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w Stage --overwrite' 2>/dev/null +aio app deploy +``` + +### CDN propagation delay + +After `aio app deploy` succeeds, CDN propagation can take 1-2 minutes. If the extension shows the old version immediately after deploy, wait a moment and hard-refresh. + +--- + +## Full Deployment Checklist + +- [ ] `aio where` shows the correct org, project, and workspace +- [ ] `aio app use -w ` run in a clean subshell (`unset CI AIO_CLI_NO_TTY TERM`) before deploying +- [ ] `aio app deploy` completed without errors (partial web-only success is still usable) +- [ ] Tested with the deployed CDN URL (not localhost) +- [ ] **Content Hub / AEM surfaces** — deployed URL opened with `?devMode=true&ext=/index.html` +- [ ] **Content Hub only** — `allowedRepos` populated with target delivery repo IDs before Production deploy +- [ ] For Production: approved in Extension Manager +- [ ] For Production: verified without `ext=` URL parameter diff --git a/plugins/app-builder/skills/appbuilder-project-init/SKILL.md b/plugins/app-builder/skills/appbuilder-project-init/SKILL.md index eec1c38a..31534b38 100644 --- a/plugins/app-builder/skills/appbuilder-project-init/SKILL.md +++ b/plugins/app-builder/skills/appbuilder-project-init/SKILL.md @@ -1,11 +1,11 @@ --- name: appbuilder-project-init -description: Initialize an Adobe App Builder project end-to-end without Developer Console UI clicks. Creates the Console project and workspace, subscribes APIs (including those needing a product profile), maps user intent to the right template, runs non-interactive `aio app init`, and guides post-init customization. Use whenever the user mentions creating an App Builder app, scaffolding a project, `aio app init`, setting up an Experience Cloud extension, adding actions or web assets, creating a Console project or workspace, adding APIs, or bootstrapping App Builder — even if they don't say "App Builder". Also for SPA templates, AEM extensions, API Mesh, Asset Compute workers, and MCP server projects. Also handles debugging init failures — template not found, `aio app init` hangs or times out, Node version mismatches, npm install failures, post-init build errors, `aio login` issues, `aio app run` showing nothing, or `aio console project create` / `workspace create` / `workspace api add` errors. +description: Initialize or scaffold an Adobe App Builder project end-to-end without Developer Console UI clicks. Creates the Console project and workspace, subscribes APIs, maps user intent to the right template, and runs non-interactive `aio app init`. Use whenever the user mentions creating an App Builder app, scaffolding a project, `aio app init`, setting up an Experience Cloud extension, adding actions or APIs, or bootstrapping App Builder — even if they don't say "App Builder". Also for SPA templates, AEM extensions, API Mesh, Asset Compute workers, and MCP projects. Also scaffolds a Content Hub extension (`aem/assets/contenthub/1`) from scratch — Console setup, file generation, build, dev server, deploy — when the user says "scaffold a Content Hub extension" or names a Content Hub surface (asset details panel, card action, bulk action). Also handles debugging init failures — `aio app init` hangs, Node mismatches, npm failures, `aio login` issues, or `aio console` project/workspace/API errors. metadata: category: project-initialization license: Apache-2.0 compatibility: Requires aio CLI (Adobe I/O CLI) — install or refresh with `npm install -g @adobe/aio-cli` so the bundled plugins (`aio-cli-plugin-console`, `aio-cli-plugin-app`, etc.) are current. Node.js 18+ (Node 24 supported on Stage runtimes). Bash shell. -allowed-tools: Bash(aio:*) Bash(npm:*) Bash(node:*) Read Write +allowed-tools: Bash(aio:*) Bash(npm:*) Bash(node:*) Bash(mkdir:*) Bash(lsof:*) Bash(kill:*) Bash(open:*) Read Write --- # App Builder Project Initialization @@ -143,6 +143,7 @@ Pick the template that matches the user's intent. When unclear, default to `@ado | User wants | Template | | --- | --- | | SPA with actions + React UI | @adobe/generator-app-excshell | +| Content Hub extension (asset details panel, card action, bulk action) | Content Hub Scaffolding — see section below | | AEM Content Fragment Console extension | @adobe/aem-cf-admin-ui-ext-tpl | | AEM React SPA (WKND-based) | @adobe/generator-app-aem-react | | Adobe API Mesh (GraphQL) | @adobe/generator-app-api-mesh | @@ -152,6 +153,14 @@ Pick the template that matches the user's intent. When unclear, default to `@ado For a headless/backend-only request, prefer `init-bare` when possible. If the user still needs a template that generates UI files, plan a post-init cleanup so the final project has no `web-src` frontend directory or web manifest wiring. +## Content Hub Extension Scaffolding + +For a Content Hub extension (`aem/assets/contenthub/1` — asset details panels, card actions, bulk actions) this skill runs a dedicated end-to-end workflow instead of a generator template: name → namespace selection → Console setup → file generation → npm install → `aio app use` → build → dev server → cert acceptance → open in Content Hub → deploy. + +Read [`references/contenthub-scaffolding.md`](references/contenthub-scaffolding.md) for the full step-by-step workflow and [`references/contenthub-templates.md`](references/contenthub-templates.md) for the scaffold file templates. The workflow's final step does a manual first `aio app deploy`; for the full deployment story (Stage → Production, Extension Manager approval, CI/CD) chain to the `appbuilder-cicd-pipeline` skill. + +**When it triggers:** the user says "create/scaffold a Content Hub extension", or names a Content Hub surface ("asset details panel", "card action button", "bulk action"). Once the extension is scaffolded and running, chain to `appbuilder-ui-scaffolder` for UI customization (React Spectrum patterns for each namespace). + ## Initialize via Script The `aio app *` wrappers go through a single script: `scripts/init.sh`. (Console bootstrap commands are called directly — see the **Bootstrap** section above for the rationale.) @@ -303,4 +312,8 @@ After initialization, hand off to: - [references/bootstrap.md](references/bootstrap.md) — Agentic Developer Console bootstrap (project, workspace, API subscriptions) via raw `aio console …` commands from the latest `@adobe/aio-cli` - [references/templates.md](references/templates.md) — Template catalog with intent mapping and per-template post-init guidance -- [references/debugging.md](references/debugging.md) — Troubleshooting guide for init failures, Node/npm issues, login problems, and first-run errors \ No newline at end of file +- [references/debugging.md](references/debugging.md) — Troubleshooting guide for init failures, Node/npm issues, login problems, and first-run errors +- [references/contenthub-scaffolding.md](references/contenthub-scaffolding.md) — Full Content Hub extension scaffolding workflow (Steps 1–17: Console setup, namespace selection, file generation, build, dev server, cert acceptance, deploy) +- [references/contenthub-templates.md](references/contenthub-templates.md) — All Content Hub scaffold file templates (`app.config.yaml`, `ext.config.yaml`, `ExtensionRegistration.js`, `App.js`, per-namespace components (`PanelAssetDetailsExtensionTab.js`, `CardActionModal.js`, `SelectionBarModal.js`), `actions/generic/index.js`) + +For deploying a Content Hub extension (Stage → Production, CDN URL, Extension Manager approval, CI/CD), use the `appbuilder-cicd-pipeline` skill — see its `references/contenthub-deploy.md`. \ No newline at end of file diff --git a/plugins/app-builder/skills/appbuilder-project-init/references/contenthub-scaffolding.md b/plugins/app-builder/skills/appbuilder-project-init/references/contenthub-scaffolding.md new file mode 100644 index 00000000..a6537c49 --- /dev/null +++ b/plugins/app-builder/skills/appbuilder-project-init/references/contenthub-scaffolding.md @@ -0,0 +1,327 @@ +# Content Hub Extension Scaffolding — Full Workflow + +Complete step-by-step workflow for scaffolding a **Content Hub** App Builder extension (`aem/assets/contenthub/1`) from scratch: Console setup, namespace selection, file generation, npm install, build, dev server, cert acceptance, and deploy. + +Read this file when `appbuilder-project-init` is asked to create/scaffold a Content Hub extension. + +> **When to use this:** the user wants to create a NEW Content Hub extension — tab panels in Asset Details, card/collection-tile action buttons, or bulk-action-bar buttons. This drives the whole lifecycle from one prompt to a running, open browser tab. If the user already has a scaffolded project and only wants to customize the UI, use `appbuilder-ui-scaffolder` (§ Content Hub in `references/aem-extensions.md`) instead. + +**Fixed facts for this surface** (used by every step below): + +| | Value | +| --- | --- | +| Extension point | `aem/assets/contenthub/1` | +| Source dir | `src/aem-assets-contenthub-1/` | +| SDK | `@adobe/uix-guest` | +| Templates | [`references/contenthub-templates.md`](contenthub-templates.md) | +| Namespaces | `assetDetails` (tab panels), `card` (asset-card + collection-tile buttons), `selectionBar` (bulk-action bar) | +| Local dev open URL | `https://experience.adobe.com/?devMode=true&ext=https://localhost:9080#/assets/contenthub/` | + +Use `AskUserQuestion` for every user decision. Never print a test URL without opening it. Never ask the user to type anything (except when they choose "Other"). + +--- + +## Interaction Rules (how to ask, decline, and resume) + +**1. Always use `AskUserQuestion` for choices.** Never print a numbered list and ask the user to type a number. The only typing the user does is selecting "Other". + +**2. Every option states what it does — never bare "Yes"/"No".** Give each option a `label` naming the action and a `description` spelling out the consequence (e.g. `label: "Yes — install the aio CLI now"`, `description: "Runs npm install -g @adobe/aio-cli, ~30s"`). The decline option must say what happens instead. + +**3. Declining must never dead-end.** When the user declines, reply with (a) a one-line confirmation of what was skipped, (b) the exact command/action to run, and (c) how to resume — tell them to type **`continue`** and which step you'll pick up from. + +**4. ALL file writes are fully automatic — no confirmation, ever.** Every `Write`/`Edit`/`Bash mkdir` in this flow is silent and automatic. Do NOT ask "Ready to scaffold?", "Should I create this file?", or announce each file. Just write them all. The only `AskUserQuestion` calls are genuine decisions (name, namespaces, workspace, output dir, install/login/deploy). + +**5. If interrupted mid-run, tell the user how to resume.** End with what completed, what's pending, and that typing **`continue`** resumes from the next step (state the step number). + +**6. When a Bash command is denied at the permission prompt, never silently halt.** Reply with which command was blocked, why it's needed, and that they can approve it (picking "Yes, and don't ask again" avoids future prompts for routine `mkdir`/`npm`/`aio` commands) or run it themselves and type **`continue`**. + +--- + +## Full Workflow + +### Step 1 — Extension Name + +Use `AskUserQuestion`. Suggest Content Hub-appropriate names: + +``` +question: "What should we name your extension?" +options: + - label: "asset-metadata-panel" + - label: "asset-card-actions" + - label: "asset-bulk-export" +``` + +The user can select "Other" to type their own kebab-case name. Store as `extensionName`. Validate kebab-case (lowercase letters + hyphens only); if invalid (e.g. `My Extension`), suggest the corrected form (`my-extension`) via another `AskUserQuestion`. + +### Step 2 — Choose Content Hub namespace(s) + +**Auto-select rule:** if the prompt already names a namespace, do NOT ask — auto-select and skip to Step 3: +- "asset card" / "card action" / "buttons on cards" → `["card"]` +- "asset details panel" / "tab panel in asset details" → `["assetDetails"]` +- "bulk action" / "selection bar" → `["selectionBar"]` +- "card and bulk actions" → `["card", "selectionBar"]` + +**Only ask when the prompt is generic** ("create a Content Hub extension" with no namespace hint). Use `AskUserQuestion` with `multiSelect: true`: + +``` +question: "Which Content Hub surfaces do you want to extend?" (multiSelect: true) +options: + - label: "Asset Details panel" + description: "Custom tab panels in the Asset Details Dialog side rail — assetDetails namespace" + - label: "Asset card / collection tile action" + description: "Buttons on asset card menus (Assets grid, inside a collection, link share) and on collection tiles — card namespace. onActionClick(resourceType, buttonId, resourceId, actionContext)." + - label: "Selection bar (bulk action)" + description: "Buttons in the bulk-action bar shown when assets are selected — selectionBar namespace. onActionClick(buttonId, assetIds[])." +``` + +Store the selected keys as `namespaces` (at least one). Notes: `card`/`selectionBar` open a modal on click via `host.modal.openDialog()`, so `ExtensionRegistration.js` uses `let guestConnection` (not `const`). Step 10 uses `namespaces` to decide which component files to write. + +### Step 3 — Workspace + +``` +question: "Which workspace should this extension use?" +options: + - label: "Stage" description: "For development and testing — recommended to start here" + - label: "Production" description: "For final release" +``` + +Store as `workspace`. + +### Step 4 — Output Directory + +``` +question: "Where should the project be created?" +options: + - label: "~/Desktop/" description: "Create on your Desktop (recommended)" + - label: "~/Documents/" description: "Create in your Documents folder" +``` + +Resolve `outputPath` to an absolute path (e.g. `$HOME/Desktop/`). + +### Step 5 — Check aio CLI Installed + +```bash +aio --version 2>/dev/null +``` + +- **Installed:** continue. +- **Not installed:** `AskUserQuestion` — "Yes — install it now" (`npm install -g @adobe/aio-cli`) or "No — I'll install it myself" (print the command, pause, resume on `continue`). + +### Step 6 — Check Login AND Validate the Token + +`aio where` alone is NOT sufficient — a token can look logged-in yet be rejected server-side with `401 ... ErrInvalidOauthToken`. This is the #1 cause of "401 on everything" — a **stale token**, not a restricted org. The only fix is `aio login --force`. + +**6a — Context check (fast, no browser):** +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio where 2>/dev/null +``` +If empty or "not logged": run login in a subshell with the flags **unset** (`env VAR=` is not enough — must `unset`): +```bash +bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login' +``` +The browser opens; wait up to 3 minutes for exit 0. Do NOT ask permission. + +**6b — Token-validity probe:** even if context looked fine, make one real Console API call: +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console org list --json 2>&1 | head -c 400 +``` +- Returns a JSON array of orgs → token valid. Cache the org list for Step 7. +- Contains `401`/`Unauthorized`/`ErrInvalidOauthToken` → stale token. Refresh **without asking** (the user authorized the flow by running the skill): + ```bash + bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login --force' + ``` + Wait for exit 0, re-run the probe to confirm it returns orgs. + +**Critical:** never run `aio login`/`login --force` with `CI`, `AIO_CLI_NO_TTY`, or `TERM=dumb` set — those suppress the browser and login silently fails. + +### Step 7 — Resolve Org + +Use the cached org list from 6b (or re-run `aio console org list --json`). +- **One org:** auto-select `aio console org select `, store `activeOrgId`. +- **Multiple:** `AskUserQuestion` (label = org name, description = orgId; mark the current one). Then `aio console org select `. +- **Zero:** broken token — `aio login --force`, retry. + +### Step 8 — Resolve Project + +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console project list --json 2>/dev/null +``` +- **Empty:** auto-create `aio console project create -n --json`, select it, `isNewProject = true`. +- **Projects exist:** always `AskUserQuestion` (one option per project + "Create a new project"). Never auto-select an existing project. On select: `aio console project select `. On create: ask for an alphanumeric name (no hyphens), create, select. +- If `create` errors "already exists": list, select the match. +- If `project list` returns 401 here: stale token mid-flow — `aio login --force`, retry (never fall back to `aio where` values). + +Store `activeProjectId`, `activeProjectName`, `isNewProject`. + +### Step 9 — Select Workspace + I/O Runtime note + +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console workspace select +``` +**Do NOT run `aio console workspace service add`** — that subcommand does not exist in current aio CLI and isn't needed: every App Builder workspace gets an I/O Runtime namespace automatically, and `aio app use` (Step 12) downloads the creds into `.env`. The #1 reason `.env` comes back empty is a stale token, not a missing entitlement. + +If `isNewProject = false`, create the workspace if missing: +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console workspace list --projectName --json 2>/dev/null +# if not present: +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console workspace create --projectName --name --json 2>/dev/null +``` + +### Step 10 — Scaffold Project Files + +**FULLY AUTOMATIC — start writing immediately, zero questions.** Say "Scaffolding project files to ``..." then `mkdir` + `Write` every file in one uninterrupted sequence. + +```bash +mkdir -p /src/aem-assets-contenthub-1/web-src/src/components +mkdir -p /src/aem-assets-contenthub-1/actions/generic +mkdir -p /hooks +``` + +Read [`references/contenthub-templates.md`](contenthub-templates.md) and write all scaffold files, substituting `{{EXTENSION_NAME}}`, `{{DISPLAY_NAME}}`, `{{EXTENSION_DESCRIPTION}}`: + +- `package.json`, `app.config.yaml`, `src/aem-assets-contenthub-1/ext.config.yaml`, `extension-manifest.json`, `.eslintrc.js`, `hooks/post-deploy.js` +- `src/aem-assets-contenthub-1/web-src/index.html`, `web-src/src/index.js`, `index.css`, `config.json` +- `web-src/src/components/Constants.js`, `App.js`, `ExtensionRegistration.js` +- `src/aem-assets-contenthub-1/actions/utils.js`, `actions/generic/index.js` +- Namespace components — write **only** for namespaces selected in Step 2: `PanelAssetDetailsExtensionTab.js` (assetDetails), `CardActionModal.js` (card), `SelectionBarModal.js` (selectionBar) + +Keep `App.js` routes and the `ExtensionRegistration.js` `methods` object limited to the selected namespaces. + +### Step 11 — npm install + +```bash +cd +npm install +``` +If E401 (registry auth): `npm install --registry https://registry.npmjs.org`. + +### Step 12 — Wire to Workspace (`aio app use`) + +**Must run in a subshell with CI/AIO_CLI_NO_TTY unset**, or credential download is silently skipped and `.env` stays empty → 401 on build/deploy: +```bash +cd +bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio app use -w --overwrite --no-input' +``` +If non-zero: +```bash +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w --overwrite' +``` +Check `.env`: +```bash +node -e "const fs=require('fs');try{const c=fs.readFileSync('/.env','utf8');console.log(/AIO_RUNTIME_NAMESPACE=\S+/.test(c)&&/AIO_RUNTIME_AUTH=\S+/.test(c)?'VALID':'INVALID')}catch(e){console.log('INVALID')}" +``` +**If INVALID:** almost always a stale token — `aio login --force`, then re-run `aio app use`. Do NOT run `workspace service add`. Only if `.env` is still empty after a confirmed-fresh token is the workspace genuinely without Runtime (rare; UI still works, only actions unavailable). + +### Step 13 — Build + +```bash +cd +aio app build +``` +If a config validation error: `aio app build --no-config-validation`, fix the YAML, rebuild without the flag. + +### Step 14 — Start Dev Server + +Free port 9080 if taken (the user authorized the flow — don't ask): +```bash +lsof -ti:9080 # if a PID: kill 2>/dev/null; sleep 2 (kill -9 if still held) +``` +Start in background with `PORT=9080` explicitly, poll the log for the localhost URL: +```bash +cd +PORT=9080 aio app run > /tmp/aio-run-.log 2>&1 & +for i in $(seq 1 60); do grep -qE "https?://localhost:[0-9]+" /tmp/aio-run-.log 2>/dev/null && break; sleep 2; done +cat /tmp/aio-run-.log +``` +- URL appears → proceed to Step 15. +- 120s with no URL → `AskUserQuestion`: "Yes — rebuild and retry" (re-run build then the run loop) or "No — I'll start it manually" (print `cd && PORT=9080 aio app run`, resume on `continue` from Step 15). + +### Step 15 — Open Cert Page (after dev server URL confirmed) + +```bash +open "https://localhost:9080" # xdg-open on Linux, start "" on Windows +``` +Then `AskUserQuestion`: "Done — open the extension" (proceed to Step 16) or "Reopen the cert page" (re-`open` and ask again). The panel stays blank until the self-signed cert is accepted (Advanced → Proceed to localhost, or type `thisisunsafe`). + +### Step 16 — Open Content Hub Automatically + +Only after "Done" — open via Bash, **never just print the URL**: +```bash +open "https://experience.adobe.com/?devMode=true&ext=https://localhost:9080#/assets/contenthub/" +``` +Rules: **no `&repo=`** (the scaffold sets `allowedRepos = []`, so any repo works for local dev); **no `/index.html`** after `localhost:9080` (that's only for deployed CDN URLs). + +### Step 17 — Ask About Deployment + +The workspace was chosen in Step 3 — don't ask again. Just: +``` +question: "Extension is running locally on . Deploy it now?" +options: + - label: "Yes — deploy to now" description: "Runs aio app deploy, then opens the deployed CDN test URL automatically" + - label: "Not now — keep running locally" description: "Stays on localhost:9080; type 'deploy' later to deploy from Step 17" +``` +If "Yes": run the manual first-deploy now. In brief: +```bash +cd +aio app deploy 2>&1 | tee /tmp/aio-deploy-.log +``` +Parse the `*.adobeio-static.net` CDN base from the log (regardless of exit code) and **open** the deployed test URL — never just print it: +```bash +open "https://experience.adobe.com/?devMode=true&ext=/index.html#/assets/contenthub/" +``` +**Partial failure is still success:** if web assets deployed but actions failed (no Runtime / 401), the UI still works — open the CDN URL and note actions are unavailable; don't treat it as blocking. If `workspace` is Production, after opening offer `AskUserQuestion` to open Extension Manager (`https://experience.adobe.com/aem/extension-manager`) for approval. + +For the full deployment story — Stage → Production promotion, CDN URL parsing, Extension Manager approval, re-deploy after code changes, and automated CI/CD pipelines — hand off to the **`appbuilder-cicd-pipeline`** skill (see its `references/contenthub-deploy.md`). + +**After Step 17 (deployed or local), ALWAYS print the "Where to edit" map** — filter rows to the namespaces selected in Step 2: + +``` +## Where to edit your extension +All UI files are under `src/aem-assets-contenthub-1/web-src/src/components/` + +| What you want to change | File to edit | +|---|---| +| Which panels / buttons appear, their title, icon, or label | `ExtensionRegistration.js` | +| Asset Details panel content | `PanelAssetDetailsExtensionTab.js` ← only if assetDetails selected | +| Card action modal content | `CardActionModal.js` ← only if card selected | +| Selection bar (bulk action) content | `SelectionBarModal.js` ← only if selectionBar selected | +| Server-side logic / AEM API calls | `actions/generic/index.js` | +``` + +Then: *"Let me know what you'd like to build and I'll make the changes."* (chains to `appbuilder-ui-scaffolder`). + +--- + +## Failure Recovery + +| Symptom | Action | +| --- | --- | +| `aio where` shows "not logged" | `bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login'` — don't ask, browser opens, wait for exit 0 | +| `aio console *` returns 401 / `ErrInvalidOauthToken` (even though `aio where` looks logged-in) | **Stale token, NOT a restricted org.** `aio login --force`, retry. Never fall back to `aio where` values. | +| `aio console project create` → "already exists" | List projects, select the match, continue | +| `aio console workspace create` → "already exists" | Skip, continue | +| `aio app use` exits non-zero | `bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio app use -w --overwrite --no-input'` | +| `.env` missing `AIO_RUNTIME_NAMESPACE` | Usually a stale token — `aio login --force` then re-run `aio app use`. Do NOT run `workspace service add`. | +| `npm install` → E401 | `npm install --registry https://registry.npmjs.org` | +| `aio app build` → validation error | `aio app build --no-config-validation`; fix YAML; rebuild without flag | +| `aio app run` log never shows `localhost:9080` | Re-run `aio app build`; check `node -v` ≥ 18; read `/tmp/aio-run-.log` | +| Panel blank in browser | Cert not accepted — `open https://localhost:9080` and ask again | +| Content Hub panel not visible | Check `allowedRepos` is empty; URL uses `#/assets/contenthub/`; `devMode=true` present; card/selectionBar require the `EXTENSIBILITY_AEM_CONTENTHUB` flag | +| Deploy: CDN URL not found in log | `cat /tmp/aio-deploy-.log`; look for any `.adobeio-static.net` URL | + +--- + +## Quality Checklist + +- [ ] `app.config.yaml` uses `aem/assets/contenthub/1` +- [ ] Source directory is `src/aem-assets-contenthub-1/` +- [ ] `ExtensionRegistration.js` imports `register`, secondary pages import `attach`, both use the same `extensionId` from `Constants.js` +- [ ] `ExtensionRegistration.js` uses `let guestConnection` if `card` or `selectionBar` is selected +- [ ] Only the selected namespaces' components and routes exist +- [ ] `npm install` succeeded (Step 11) +- [ ] `aio app use` ran in a subshell with `unset CI AIO_CLI_NO_TTY TERM` (Step 12) +- [ ] `.env` has `AIO_RUNTIME_NAMESPACE` and `AIO_RUNTIME_AUTH` +- [ ] `aio app build` succeeded (Step 13) +- [ ] `PORT=9080 aio app run` is running and the log shows `localhost:9080` (Step 14) +- [ ] Cert page opened via `open` **after** the dev server URL was confirmed (Step 15) +- [ ] Content Hub opened via `open` (Bash), not just printed (Step 16) diff --git a/plugins/app-builder/skills/appbuilder-project-init/references/contenthub-templates.md b/plugins/app-builder/skills/appbuilder-project-init/references/contenthub-templates.md new file mode 100644 index 00000000..6a00e575 --- /dev/null +++ b/plugins/app-builder/skills/appbuilder-project-init/references/contenthub-templates.md @@ -0,0 +1,1028 @@ +# Content Hub Extension File Templates + +Complete templates for every Content Hub extension scaffold file. Before writing, substitute: +- `{{EXTENSION_NAME}}` → kebab-case name (e.g. `my-asset-viewer`) +- `{{DISPLAY_NAME}}` → Title Case name (e.g. `My Asset Viewer`) +- `{{EXTENSION_DESCRIPTION}}` → one-sentence description + +Extension point used throughout: `aem/assets/contenthub/1` +Source directory: `src/aem-assets-contenthub-1/` + +> Only write the namespace components you need — `PanelAssetDetailsExtensionTab.js` (assetDetails), `CardActionModal.js` (card), `SelectionBarModal.js` (selectionBar). The shared skeleton (`package.json`, `index.html`, `index.js`, `index.css`, `Constants.js`, `App.js`, `actions/*`, `hooks/post-deploy.js`, `.eslintrc.js`) is always written. + + +--- + +## `package.json` + +```json +{ + "name": "{{EXTENSION_NAME}}", + "version": "1.0.0", + "description": "{{EXTENSION_DESCRIPTION}}", + "author": "", + "license": "Apache-2.0", + "scripts": { + "test": "jest --passWithNoTests --testPathIgnorePatterns web-src", + "dev": "aio app run", + "build": "aio app build", + "deploy": "aio app deploy", + "undeploy": "aio app undeploy" + }, + "dependencies": { + "@adobe/aio-sdk": "^5.0.0", + "@adobe/uix-guest": "0.10.5", + "@adobe/react-spectrum": "^3.0.0", + "chalk": "^4.0.0", + "js-yaml": "^4.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-router-dom": "^6.0.0", + "react-error-boundary": "^4.0.0" + }, + "devDependencies": { + "@adobe/eslint-config-aio-lib-config": "^3.0.0", + "eslint": "^8.0.0", + "jest": "^29.0.0" + }, + "engines": { + "node": "^18 || ^20" + } +} +``` + +--- + +## `app.config.yaml` + +```yaml +extensions: + aem/assets/contenthub/1: + $include: src/aem-assets-contenthub-1/ext.config.yaml +``` + +**Critical (Content Hub):** Use `aem/assets/contenthub/1` — not the deprecated `aem/contenthub/assets/details/1`. If the user's existing project has the old extension point ID, update both this file and the directory name. + +--- + +## `src/aem-assets-contenthub-1/ext.config.yaml` + +```yaml +$schema: https://unpkg.com/@adobe/aio-schemas@latest/schemas/aio.schema.json +actions: actions +web: web-src +runtimeManifest: + packages: + aem-assets-contenthub-1: + license: Apache-2.0 + actions: + generic: + function: actions/generic/index.js + web: 'yes' + runtime: 'nodejs:18' + inputs: + LOG_LEVEL: debug + annotations: + require-adobe-auth: false + final: true +operations: + view: + - type: web + impl: index.html +hooks: + post-app-deploy: ./hooks/post-deploy.js +``` + +--- + +## `extension-manifest.json` + +```json +{ + "name": "{{EXTENSION_NAME}}", + "id": "aem-assets-contenthub-1", + "description": "{{EXTENSION_DESCRIPTION}}", + "version": "1.0.0", + "engines": { + "aio-cli": ">=10.0.0" + }, + "keywords": ["contenthub", "assets", "extension", "uix"], + "author": "", + "license": "Apache-2.0", + "extensionPoints": ["aem/assets/contenthub/1"] +} +``` + +--- + +## `.eslintrc.js` + +```js +module.exports = { + root: true, + extends: ['@adobe/eslint-config-aio-lib-config'], + env: { + node: true, + es2020: true, + browser: true + }, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { jsx: true } + } +}; +``` + +--- + +## `AGENTS.md` + +```markdown +# {{DISPLAY_NAME}} — Agent Guidelines + +## Extension Point + +`aem/assets/contenthub/1` — Content Hub extensibility. + +## Active Namespaces + +- `assetDetails` — tab panels in the Asset Details Dialog side rail +- `card` — action buttons on asset cards (Assets grid, collection, link share) and on collection tiles in the Collections grid +- `selectionBar` — bulk action buttons in the selection bar (multi-select) + +(Remove entries for namespaces not wired in ExtensionRegistration.js) + +## Project Structure + +- `src/aem-assets-contenthub-1/web-src/` — React SPA (Spectrum UI, UIX Guest) +- `src/aem-assets-contenthub-1/actions/` — Adobe I/O Runtime web actions +- `app.config.yaml` — declares `aem/assets/contenthub/1` extension point +- `src/aem-assets-contenthub-1/ext.config.yaml` — runtime manifest + +## Building & Running + +Use `aio` CLI commands (not npm scripts directly): +- `aio app build` — build and deploy actions to I/O Runtime, compile web-src +- `aio app run` — start local dev server at https://localhost:9080 +- `aio app deploy` — full deployment to Stage/Production + +## Local Test URL + +``` +https://experience.adobe.com/?devMode=true&ext=https://localhost:9080#/assets/contenthub/ +``` + +First time: accept the self-signed cert at https://localhost:9080. + +## Key Files to Customize + +- `ExtensionRegistration.js` — declares all namespaces. Uses `let guestConnection` so `onActionClick` can call `host.modal.openDialog()` after `register()` resolves. +- `PanelAssetDetailsExtensionTab.js` — panel UI for `assetDetails` namespace (rendered in iframe) +- `CardActionModal.js` — modal UI for `card` namespace; reads its data from the `contentUrl` query (`URLSearchParams`), NOT `getPayload()` +- `SelectionBarModal.js` — modal UI for `selectionBar` namespace; reads `assetIds[]` from the `contentUrl` query (`URLSearchParams` + `JSON.parse`), NOT `getPayload()` +- `actions/generic/index.js` — server-side logic for AEM API calls + +## Host APIs (via `guestConnection.host` in any attached page) + +- `auth.getIMSInfo()` → `{ imsOrg, imsOrgName, accessToken }` +- `auth.getApiKey()` → API key string ← note: `getApiKey`, not `getAPIKey` +- `discovery.getAemHost()` → `https:///` +- `toast.display({ variant, message })` → show toast; variants: `neutral`, `positive`, `negative`, `info` +- `i18n.getLocalizationInfo()` → `{ locale }` +- `modal.openDialog({ title, contentUrl, type?, size?, payload? })` → open modal dialog. **Single config object from the guest's side** — the UIX host auto-injects `{ id }`; never pass it yourself. Pass data to the modal either via the `contentUrl` query string (e.g. `/#card-action-modal?resourceId=...&resourceType=...`) or via `payload`. +- `modal.getPayload()` → returns the `payload` passed to `openDialog()`. Call from the modal page after `attach()`. +- `modal.closeDialog()` → close the current modal (call from the modal page after `attach()`) + +## assetDetails: getCurrentAsset() + +`assetDetails.getCurrentAsset()` returns the asset id as a plain **STRING** (e.g. `"urn:aaid:aem:..."`), NOT an object. Use it directly or wrap: `const asset = { id: assetId }`. + +## card: getActionButtons receives actionContext + +The host calls `getActionButtons(actionContext)` with: +- `actionContext.context`: `'assets'` | `'collection'` | `'collections'` | `'share'` — the source view. + `assets`, `collection`, and `share` are asset-card surfaces; `collections` is a collection tile + on the Collections grid. Use it to vary buttons per surface, or ignore it to show the same set. + +The same `card` namespace serves both asset cards and collection tiles — distinguish them via +`actionContext.context` (and `resourceType` on click). + +## card: onActionClick signature + +Called by the host as `onActionClick(resourceType, buttonId, resourceId, actionContext)`: +- `resourceType`: `'asset'` (asset cards) or `'collection'` (collection tiles) +- `buttonId`: the `id` from `getActionButtons()` +- `resourceId`: the asset or collection URN string +- `actionContext`: `{ context }` — same surface values as above + +## selectionBar: getActionButtons receives actionContext + +The host calls `selectionBar.getActionButtons(actionContext)` with: +- `actionContext.context`: `'assets'` | `'collections'` | `'collection'` | `'share'` — the source view +- `actionContext.resourceSelection.resources`: `[{id: string}, ...]` — the current selection + +Use this to conditionally show/hide buttons depending on where the bar appears, or ignore it. + +## selectionBar: onActionClick signature + +Called by the host as `onActionClick(buttonId, assetIds)`: +- `buttonId`: the `id` from `getActionButtons()` +- `assetIds`: `string[]` — array of selected asset URNs + +Note: the host prefixes the rendered button id as `ext::` to avoid collisions with native actions. Your extension code uses the original `btn.id` — the prefix is host-internal only. +``` + +--- + +## `hooks/post-deploy.js` + +```js +const chalk = require('chalk'); +const fs = require('fs'); +const yaml = require('js-yaml'); + +module.exports = (config) => { + try { + const yamlFile = fs.readFileSync(`${config.root}/app.config.yaml`, 'utf8'); + const yamlData = yaml.load(yamlFile); + const { extensions } = yamlData; + const extension = Object.keys(extensions)[0]; + const previewData = { + extensionPoint: extension, + url: config.project.workspace.app_url, + }; + const base64EncodedData = Buffer.from(JSON.stringify(previewData)).toString('base64'); + console.log(chalk.magenta(chalk.bold('For a developer preview of your UI extension in the Content Hub environment, follow the URL:'))); + const env = process.env.AIO_CLI_ENV === 'stage' ? '-stage' : ''; + console.log(chalk.magenta(chalk.bold(` -> https://experience${env}.adobe.com/aem/extension-manager/preview/${base64EncodedData}`))); + } catch (_) { + // Non-fatal: just skip the preview URL + } +}; +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/index.html` + +```html + + + + + + {{EXTENSION_NAME}} + + + +
+ + + +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/index.js` + +```js +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './components/App.js'; +import './index.css'; + +const root = createRoot(document.getElementById('root')); +root.render(); +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/index.css` + +```css +html, body { + margin: 0; +} +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/config.json` + +```json +{ + "aem-assets-contenthub-1/generic": "https://localhost:9080/api/v1/web/aem-assets-contenthub-1/generic" +} +``` + +This file is overwritten by `aio app run` (localhost URL) and `aio app deploy` (cloud URL). Do not edit manually. + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/Constants.js` + +```js +export const extensionId = 'sample-extension'; +``` + +The `extensionId` **must** be identical in `register()` (ExtensionRegistration.js) and `attach()` (PanelAssetDetailsExtensionTab.js). Both import from this file. + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/App.js` + +Include only the imports and routes for the namespaces selected in Step 2. Remove unused imports/routes. + +```js +import React from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { HashRouter as Router, Routes, Route } from 'react-router-dom'; +import ExtensionRegistration from './ExtensionRegistration'; +import PanelAssetDetailsExtensionTab from './PanelAssetDetailsExtensionTab'; // assetDetails namespace — remove if not selected +import CardActionModal from './CardActionModal'; // card namespace — remove if not selected +import SelectionBarModal from './SelectionBarModal'; // selectionBar namespace — remove if not selected + +function App() { + return ( + + + + } /> + } /> + {/* assetDetails namespace — remove if not selected */} + } /> + {/* card namespace — remove if not selected */} + } /> + {/* selectionBar namespace — remove if not selected */} + } /> + + + + ); + + function onError(e, componentStack) { + console.error('Extension error:', e, componentStack); + } + + function fallbackComponent({ componentStack, error }) { + return ( + +

Extension rendering error

+
{componentStack + '\n' + error.message}
+
+ ); + } +} + +export default App; +``` + +**Adding more panels:** Add a new `} />` here, and a matching `contentUrl: '/#my-second-panel'` entry in `ExtensionRegistration.js`. + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/ExtensionRegistration.js` + +Contains all three Content Hub namespaces. **When scaffolding, include only the namespace blocks the user selected in Step 2.** Each block is clearly marked — remove unused ones. `card` and `selectionBar` require `let guestConnection` (not `const`) because `onActionClick` is called after `register()` resolves. + +```js +import React from 'react'; +import { Text, View } from '@adobe/react-spectrum'; +import { register } from '@adobe/uix-guest'; +import { extensionId } from './Constants'; + +// Restrict extension to specific repos. +// Format: 'delivery-pXXX-eYYY.adobeaemcloud.com' +// Empty array = allow any repo (safe for development; populate before deploying to Production). +const allowedRepos = [ + // 'delivery-p12345-e167890.adobeaemcloud.com', +]; + +function getRepo() { + return new URLSearchParams(window.location.search).get('repo'); +} + +function shouldSkipRegistration(repo) { + return allowedRepos.length > 0 && !allowedRepos.includes(repo); +} + +function ExtensionRegistration() { + const repo = getRepo(); + + if (shouldSkipRegistration(repo)) { + return IFrame for integration with Host (Content Hub), Skipped registration as repo is not allowed; + } + + const init = async () => { + // `let` (not `const`) so card/selectionBar onActionClick handlers can close over it after register() resolves. + let guestConnection = await register({ + id: extensionId, + methods: { + + // ── ASSET DETAILS NAMESPACE ────────────────────────────────────────── + // Tab panels in the Asset Details Dialog side rail. + // Remove this block if assetDetails was not selected. + assetDetails: { + getTabPanels() { + return [ + { + id: '{{EXTENSION_NAME}}-panel', + title: '{{DISPLAY_NAME}}', + tooltip: '{{DISPLAY_NAME}}', + icon: 'Extension', // React-Spectrum workflow icon name + contentUrl: '/#asset-details-extension-tab', // must match a in App.js + }, + ]; + }, + }, + + // ── ASSET CARD NAMESPACE ───────────────────────────────────────────── + // Buttons on individual asset card menus (3-dot / overlay) AND on collection + // tiles in the Collections grid (the tile's ⋯ menu). Remove this block if card + // was not selected. + // getActionButtons receives an actionContext from the host: + // { context: 'assets'|'collection'|'collections'|'share' } + // 'assets' (browse grid), 'collection' (assets inside a collection), + // 'share' (link share view) are asset-card surfaces; + // 'collections' is a collection tile on the Collections grid. + // onActionClick is called with (resourceType, buttonId, resourceId, actionContext) + // resourceType: 'asset' (asset cards) | 'collection' (collection tiles) + card: { + getActionButtons(actionContext) { + // Vary buttons by actionContext.context, or ignore it to show the same set. + return [ + { + id: '{{EXTENSION_NAME}}-card-action', + label: '{{DISPLAY_NAME}}', + icon: 'Edit', // React-Spectrum workflow icon name + }, + ]; + }, + async onActionClick(resourceType, buttonId, resourceId, actionContext) { + // openDialog takes a SINGLE config object — NO { id } first arg, NO payload field. + // Pass data to the modal via the contentUrl query string (read it there with URLSearchParams). + await guestConnection.host.modal.openDialog({ + title: '{{DISPLAY_NAME}}', + contentUrl: `/#card-action-modal?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}`, // route in App.js + query data + type: 'modal', + size: 'M', + }); + }, + }, + + // ── SELECTION BAR NAMESPACE ────────────────────────────────────────── + // Bulk action buttons in the selection bar (shown when assets are selected). + // Remove this block if selectionBar was not selected. + // + // getActionButtons receives an actionContext from the host: + // { context: 'assets'|'collections'|'collection'|'share', + // resourceSelection: { resources: [{id: string}, ...] } } + // Use it to conditionally show/hide buttons per source, or ignore it to always show. + // + // onActionClick is called by the host with (buttonId, assetIds[]) + selectionBar: { + getActionButtons(actionContext) { + // actionContext.context tells you where the selection bar is shown: + // 'assets' (browse grid), 'collections' (collections list), + // 'collection' (inside a collection), 'share' (link share view). + // actionContext.resourceSelection.resources is the current selection as [{id}, ...]. + return [ + { + id: '{{EXTENSION_NAME}}-bulk-action', + label: '{{DISPLAY_NAME}}', + icon: 'Download', // React-Spectrum workflow icon name + }, + ]; + }, + async onActionClick(buttonId, assetIds) { + // Single config object — NO { id }, NO payload. Pass assetIds via the contentUrl query. + const ids = encodeURIComponent(JSON.stringify(assetIds || [])); + await guestConnection.host.modal.openDialog({ + title: `{{DISPLAY_NAME}} (${assetIds.length} asset${assetIds.length !== 1 ? 's' : ''})`, + contentUrl: `/#selection-bar-modal?assetIds=${ids}`, // route in App.js + query data + type: 'modal', + size: 'M', + }); + }, + }, + + }, + }); + }; + + init().catch(console.error); + return ( + + + + Certificate accepted! + + + Return to Claude Code in your terminal and click{' '} + "Done — open the extension" to continue. + + + ); +} + +export default ExtensionRegistration; +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/PanelAssetDetailsExtensionTab.js` + +This is the panel content rendered inside Content Hub's iframe. All Host API calls go through `guestConnection.host`. + +```js +import React, { useState, useEffect } from 'react'; +import { attach } from '@adobe/uix-guest'; +import { + Provider, + defaultTheme, + View, + Heading, + Text, + Button, + ProgressCircle, + Divider, +} from '@adobe/react-spectrum'; +import { extensionId } from './Constants'; +import actions from '../config.json'; + +export default function PanelAssetDetailsExtensionTab() { + const [guestConnection, setGuestConnection] = useState(null); + const [asset, setAsset] = useState(null); + const [loading, setLoading] = useState(true); + const [actionResponse, setActionResponse] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + try { + // Reconnect to the registered extension. + // extensionId must match the id used in register() — both come from Constants.js. + const connection = await attach({ id: extensionId }); + setGuestConnection(connection); + + // getCurrentAsset() returns the asset id as a plain string (e.g. "urn:aaid:aem:..."). + // Note: this is Content Hub's API. Assets View uses a different method. + const assetId = await connection.host.assetDetails.getCurrentAsset(); + setAsset({ id: assetId }); + + // ── Call a web action with the asset ID ──────────────────────────── + // Uncomment and customize for AEM API calls: + // + // const { accessToken, imsOrg } = await connection.host.auth.getIMSInfo(); + // const apiKey = await connection.host.auth.getApiKey(); + // const aemHost = await connection.host.discovery.getAemHost(); + // const actionUrl = actions['aem-assets-contenthub-1/generic']; + // + // const response = await fetch(actionUrl, { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // // Add these when action has require-adobe-auth: true: + // // 'Authorization': `Bearer ${accessToken}`, + // // 'x-gw-ims-org-id': imsOrg, + // }, + // body: JSON.stringify({ assetId: currentAsset.id, aemHost, apiKey, imsOrg }), + // }); + // const data = await response.json(); + // setActionResponse(data); + + } catch (err) { + console.error('Panel initialization error:', err); + setError('Failed to initialize panel: ' + err.message); + } finally { + setLoading(false); + } + })(); + }, []); + + function displayToast(variant, message) { + if (guestConnection) { + guestConnection.host.toast.display({ variant, message }); + } + } + + if (loading) { + return ( + + + + + + + + ); + } + + return ( + + + {{DISPLAY_NAME}} + + + + {error && ( + + {error} + + )} + + {asset && ( + + Asset ID: + + + {asset.id} + + + + )} + + {actionResponse && ( + + + Action Response: + + {JSON.stringify(actionResponse, null, 2)} + + + )} + + + + + ); +} +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/CardActionModal.js` + +Modal opened when a card action button is clicked. Reads the data passed via the `contentUrl` query string (the simpler alternative to `openDialog()`'s `payload`/`getPayload()` — either works), and uses `attach()` only so it can call `closeDialog()`. Only scaffold this file if `card` was selected in Step 2. + +```js +import React, { useState, useEffect } from 'react'; +import { attach } from '@adobe/uix-guest'; +import { + Provider, + defaultTheme, + View, + Text, + Button, + ProgressCircle, +} from '@adobe/react-spectrum'; +import { extensionId } from './Constants'; + +export default function CardActionModal() { + const [guestConnection, setGuestConnection] = useState(null); + const [payload, setPayload] = useState(null); + + useEffect(() => { + (async () => { + // Read data from the modal URL query (alternative: openDialog()'s payload + host.modal.getPayload()). + // contentUrl was `/#card-action-modal?resourceId=...&resourceType=...`. + const params = new URLSearchParams(window.location.hash.split('?')[1] || ''); + setPayload({ resourceId: params.get('resourceId'), resourceType: params.get('resourceType') }); + // attach() is only needed so the Close button can call host.modal.closeDialog(). + const connection = await attach({ id: extensionId }); + setGuestConnection(connection); + })(); + }, []); + + if (!payload) { + return ( + + + + + + + + ); + } + + return ( + + + + + Resource Type + + {payload.resourceType} + + + + + Resource ID + + + + {payload.resourceId} + + + + + {/* Add your custom UI here */} + + + + + ); +} +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/SelectionBarModal.js` + +Modal opened when a selection bar (bulk action) button is clicked. Reads `assetIds[]` from the `contentUrl` query (the simpler alternative to `openDialog()`'s `payload`/`getPayload()` — either works). Only scaffold this file if `selectionBar` was selected in Step 2. + +```js +import React, { useState, useEffect } from 'react'; +import { attach } from '@adobe/uix-guest'; +import { + Provider, + defaultTheme, + View, + Text, + Button, + ProgressCircle, + ListView, + Item, +} from '@adobe/react-spectrum'; +import { extensionId } from './Constants'; + +export default function SelectionBarModal() { + const [guestConnection, setGuestConnection] = useState(null); + const [payload, setPayload] = useState(null); + + useEffect(() => { + (async () => { + // Read assetIds from the modal URL query (alternative: openDialog()'s payload + host.modal.getPayload()). + // contentUrl was `/#selection-bar-modal?assetIds=`. + const params = new URLSearchParams(window.location.hash.split('?')[1] || ''); + const raw = params.get('assetIds'); + setPayload({ assetIds: raw ? JSON.parse(raw) : [] }); + // attach() is only needed so the Close button can call host.modal.closeDialog(). + const connection = await attach({ id: extensionId }); + setGuestConnection(connection); + })(); + }, []); + + if (!payload) { + return ( + + + + + + + + ); + } + + return ( + + + + + {payload.assetIds.length} asset{payload.assetIds.length !== 1 ? 's' : ''} selected + + + + + ({ id }))}> + {item => ( + + {item.id} + + )} + + + + {/* Add your bulk-action logic here */} + + + + + ); +} +``` + +--- + +## `src/aem-assets-contenthub-1/actions/utils.js` + +```js +function errorResponse(statusCode, message) { + return { statusCode, body: { error: message } }; +} + +function getBearerToken(params) { + const authHeader = params.__ow_headers?.authorization || params.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + return authHeader.substring(7); + } + throw new Error('Missing or invalid authorization header'); +} + +function checkMissingRequestInputs(params, requiredParams) { + const missing = requiredParams.filter(param => !params[param]); + if (missing.length > 0) { + return `Missing required parameters: ${missing.join(', ')}`; + } + return null; +} + +module.exports = { errorResponse, getBearerToken, checkMissingRequestInputs }; +``` + +--- + +## `src/aem-assets-contenthub-1/actions/generic/index.js` + +The web action called by the panel. Customize with real AEM API logic. + +```js +const { errorResponse, getBearerToken, checkMissingRequestInputs } = require('../utils'); + +/** + * Generic Web Action — {{DISPLAY_NAME}} + * + * Called from PanelAssetDetailsExtensionTab.js with the current asset ID. + * Customize this to call AEM Assets Author API. + * + * Params passed from the panel: + * assetId — asset urn from host.assetDetails.getCurrentAsset() + * aemHost — AEM author URL from host.discovery.getAemHost() + * apiKey — from host.auth.getApiKey() (never hardcode) + * imsOrg — from host.auth.getIMSInfo() + * + * To call authenticated AEM APIs: + * 1. Set require-adobe-auth: true in ext.config.yaml + * 2. Send Authorization header from the panel + * 3. Uncomment the fetch block below + */ +async function main(params) { + console.log('{{EXTENSION_NAME}} action called', JSON.stringify({ assetId: params.assetId }, null, 2)); + + try { + // Uncomment for authenticated AEM API calls: + // const token = getBearerToken(params); + // const { assetId, aemHost, apiKey, imsOrg } = params; + // + // const response = await fetch(`https://${aemHost}/adobe/assets/${assetId}/metadata`, { + // headers: { + // 'Authorization': `Bearer ${token}`, + // 'X-Api-Key': apiKey, // always from frontend — never hardcode + // 'x-gw-ims-org-id': imsOrg, + // 'Content-Type': 'application/json', + // }, + // }); + // const data = await response.json(); + // const metadata = data.value ?? data; + // return { statusCode: 200, body: { metadata } }; + + return { + statusCode: 200, + body: { + message: 'Action executed successfully', + extension: '{{EXTENSION_NAME}}', + timestamp: new Date().toISOString(), + assetId: params.assetId || null, + }, + }; + + } catch (error) { + console.error('Action error:', error); + return errorResponse(500, `Action failed: ${error.message}`); + } +} + +exports.main = main; +``` + +--- + +## `README.md` + +```markdown +# {{DISPLAY_NAME}} + +Content Hub UI extension for the `aem/assets/contenthub/1` extension point. + +## Extension Point + +`aem/assets/contenthub/1` — adds custom tab panels to the Asset Details Dialog. + +## Project Structure + +``` +src/aem-assets-contenthub-1/ + web-src/src/components/ + ExtensionRegistration.js — registers tab panels with Content Hub + PanelAssetDetailsExtensionTab.js — custom panel UI (rendered in iframe) + App.js — React routing + Constants.js — extensionId (shared between register + attach) + actions/ + generic/index.js — I/O Runtime web action (AEM API calls) + utils.js — shared action utilities + ext.config.yaml — runtime manifest + action definitions +app.config.yaml — declares aem/assets/contenthub/1 extension point +``` + +## Development + +```bash +npm install +aio app build +aio app run +``` + +Test URL: +``` +https://experience.adobe.com/?devMode=true&ext=https://localhost:9080#/assets/contenthub/ +``` + +No `&repo=` needed for local dev — the scaffold sets `allowedRepos = []`, so any repo (or none) works. + +> First time only: accept the self-signed cert at https://localhost:9080 before loading the test URL. + +## Allowed Repos + +Before deploying to Production, update `allowedRepos` in `ExtensionRegistration.js` with your delivery repo IDs — this restricts which AEM repos may load the extension: + +```js +const allowedRepos = ['delivery-p12345-e167890.adobeaemcloud.com']; +``` + +## Deploy + +**Stage:** `aio app use -w Stage && aio app deploy` + +**Production:** `aio app use -w Production && aio app deploy` + +Approve in [Extension Manager](https://experience.adobe.com/aem/extension-manager) after deploying to Production. +``` + +--- + +## Adding a Second Panel + +When the user wants a second tab panel, make these changes: + +### In `ExtensionRegistration.js` — add to the `getTabPanels()` array: + +```js +getTabPanels() { + return [ + { + id: '{{EXTENSION_NAME}}-panel', + title: '{{DISPLAY_NAME}}', + tooltip: '{{DISPLAY_NAME}}', + icon: 'Extension', + contentUrl: '/#asset-details-extension-tab', + }, + { + id: '{{EXTENSION_NAME}}-second-panel', + title: 'Second Panel', + tooltip: 'Second Panel', + icon: 'Info', + contentUrl: '/#second-panel', // must match the route added in App.js + }, + ]; +}, +``` + +### In `App.js` — add a route: + +```js +import SecondPanel from './SecondPanel'; // create this component + +} /> +``` + +### Create `SecondPanel.js` — copy the `PanelAssetDetailsExtensionTab.js` template, rename the component and customize the UI. diff --git a/plugins/app-builder/skills/appbuilder-ui-scaffolder/SKILL.md b/plugins/app-builder/skills/appbuilder-ui-scaffolder/SKILL.md index 6feaa04e..dedd0df9 100644 --- a/plugins/app-builder/skills/appbuilder-ui-scaffolder/SKILL.md +++ b/plugins/app-builder/skills/appbuilder-ui-scaffolder/SKILL.md @@ -4,11 +4,12 @@ description: >- Generate React Spectrum UI components for Adobe Experience Cloud Shell SPAs and AEM UI Extensions. Provides patterns for pages, forms, data tables, dialogs, and navigation using @adobe/react-spectrum. Guides ExC Shell integration with @adobe/exc-app including runtime.done(), IMS token passthrough, - and shell theming. Guides AEM UI Extension development with @adobe/uix-guest for Content Fragment - Console, CF Editor, Universal Editor, and Assets View surfaces. Trigger on: building App Builder UI, - React Spectrum components, ExC Shell pages, forms, data tables, dialogs, modals, navigation, theming, - web-src, Spectrum design system, @adobe/exc-app, AEM extension, AEM UI extension, Content Fragment - Console, Universal Editor extension, uix-guest, @adobe/uix-guest, extension points for AEM, + and shell theming. Guides AEM UI Extension development with @adobe/uix-guest, including Content Hub + surfaces (aem/assets/contenthub/1 — asset details panels, card actions, bulk actions). + Trigger on: building App Builder UI, React Spectrum components, ExC Shell pages, forms, data tables, + dialogs, modals, navigation, theming, web-src, Spectrum design system, @adobe/exc-app, AEM extension, + AEM UI extension, Content Fragment Console, Universal Editor extension, Content Hub panel, Content Hub + card action, Content Hub bulk action, uix-guest, @adobe/uix-guest, extension points for AEM, customizing AEM surfaces. metadata: category: frontend @@ -33,7 +34,8 @@ Identify the user's intent, then read the referenced sections to generate tailor | Navigation layout | `references/ui-patterns.md` § Navigation | `Tabs`, `Breadcrumbs`, `Flex` | | ExC Shell setup | `references/shell-integration.md` | `@adobe/exc-app`, `Provider`, `defaultTheme` | | Connect UI to backend actions | `references/action-integration.md` | `fetch()` with IMS token | -| AEM UI Extension (CF Console, CF Editor, Universal Editor) | `references/aem-extensions.md` | `@adobe/uix-guest`, `register()`, `sharedContext` | +| AEM UI Extension (CF Console, CF Editor, Universal Editor, Assets View) | `references/aem-extensions.md` | `@adobe/uix-guest`, `register()`, `sharedContext` | +| Content Hub extension (panels, card actions, bulk actions) | `references/aem-extensions.md` § Content Hub | `@adobe/uix-guest`, `register()`, `attach()`, `assetDetails`/`card`/`selectionBar` namespaces, `host.modal.openDialog` | | Debug UI issues | `references/debugging.md` | Shell spinner, CORS, blank screen, auth | ## Fast Path (for clear requests) @@ -55,6 +57,8 @@ Examples of fast-path triggers: - "Build a Content Fragment Console extension" → Read `references/aem-extensions.md` § CF Console, generate directly - "Add a header menu button to the Universal Editor" → Read `references/aem-extensions.md` § Universal Editor, generate directly - "Create an AEM extension with uix-guest" → Read `references/aem-extensions.md` § Core Registration, generate directly +- "Add a Content Hub panel / card action / bulk action" → Read `references/aem-extensions.md` § Content Hub, generate directly +- "Create / scaffold a new extension from scratch" → Redirect to the `appbuilder-project-init` skill (Console setup, template selection, file generation, dev server) If there is any ambiguity — multiple patterns could fit, constraints are unclear, or the user hasn't specified enough — fall through to the full workflow below. @@ -93,6 +97,11 @@ If there is any ambiguity — multiple patterns could fit, constraints are uncle - "Build a Content Fragment Console extension with an action bar button." - "Add a custom RTE toolbar button in the Content Fragment Editor." - "Create a Universal Editor extension with a header menu button." +- "Add a custom panel to the Content Hub Asset Details Dialog." +- "Build a Content Hub extension that shows asset metadata in a side panel." +- "Add an action button to Content Hub asset cards." +- "Add a bulk action to the Content Hub selection bar." +- "Customize the Content Hub extension UI with React Spectrum." ## Inputs To Request @@ -125,12 +134,13 @@ If there is any ambiguity — multiple patterns could fit, constraints are uncle - Use `references/routing-patterns.md` for SPA routing with React Router in ExC Shell. - Use `references/action-integration.md` for calling backend actions from the SPA. - Use `references/checklist.md` for pre-handoff UI quality validation. -- Use `references/aem-extensions.md` for AEM UI Extension patterns (`@adobe/uix-guest`, Content Fragment Console/Editor, Universal Editor, Assets View). +- Use `references/aem-extensions.md` for AEM UI Extension patterns (`@adobe/uix-guest`) — Content Fragment Console/Editor, Universal Editor, Assets View, and Content Hub. - Use `references/debugging.md` for common SPA debugging scenarios (shell spinner, CORS, auth, blank screen, performance). ## Chaining -- Chains FROM `appbuilder-project-init` (after SPA project is scaffolded with `dx/excshell/1` extension) -- Works alongside `appbuilder-action-scaffolder` for full-stack features (UI calls backend actions) -- Chains TO `appbuilder-testing` (test generated UI components) -- Chains TO `appbuilder-cicd-pipeline` (deploy frontend changes) +- Chains FROM `appbuilder-project-init` — once the extension is scaffolded and running locally, this skill handles UI customization and React Spectrum patterns +- Works alongside `appbuilder-action-scaffolder` for full-stack features (UI calls backend actions — including Content Hub web actions) +- Chains TO `appbuilder-testing` (unit/component tests for the generated UI) +- Chains TO `appbuilder-cicd-pipeline` (GitHub Actions deployment of frontend changes) +- Chains TO `appbuilder-e2e-testing` (Playwright E2E tests against the deployed extension) diff --git a/plugins/app-builder/skills/appbuilder-ui-scaffolder/references/aem-extensions.md b/plugins/app-builder/skills/appbuilder-ui-scaffolder/references/aem-extensions.md index 33cd0f05..1cee38db 100644 --- a/plugins/app-builder/skills/appbuilder-ui-scaffolder/references/aem-extensions.md +++ b/plugins/app-builder/skills/appbuilder-ui-scaffolder/references/aem-extensions.md @@ -1,6 +1,6 @@ # AEM UI Extension Patterns -Patterns for building AEM UI Extensions using `@adobe/uix-guest`. These extensions customize AEM surfaces (Content Fragment Console, Content Fragment Editor, Universal Editor, Assets View) and run as App Builder apps inside iframes. +Patterns for building AEM UI Extensions using `@adobe/uix-guest`. These extensions customize AEM surfaces (Content Fragment Console, Content Fragment Editor, Universal Editor, Assets View, Content Hub) and run as App Builder apps inside iframes. **Key difference from `@adobe/exc-app`:** ExC Shell apps use `register()` from `@adobe/exc-app` with `runtime.done()`. AEM extensions use `register()` from `@adobe/uix-guest` with a `methods` object that declares extension points. The two are completely separate APIs. @@ -71,6 +71,7 @@ Extension point identifiers: - `aem/cf-editor/1` — Content Fragment Editor - `aem/universal-editor/1` — Universal Editor - `aem/assets/1` — Assets View (requires Assets Ultimate license) +- `aem/assets/contenthub/1` — Content Hub (unified — asset details, card, and selection bar surfaces via one `register()` call) --- @@ -412,6 +413,237 @@ Refer to [Assets View extension docs](https://developer.adobe.com/uix/docs/servi --- +## Content Hub Extensions (`aem/assets/contenthub/1`) + +Content Hub is a single extension point that spans **three** surfaces, each opted into via a method namespace in one `register()` call: + +- **Asset Details Dialog** (`assetDetails`) — custom tab panels in the side rail. +- **Asset card actions** (`card`) — buttons on asset cards (Assets grid, inside a collection, link-share) **and** on collection tiles in the Collections grid; the surface is passed in `actionContext.context`. +- **Selection bar / bulk actions** (`selectionBar`) — buttons in the multi-select action bar. + +> Use the deprecated ID `aem/contenthub/assets/details/1` only for older projects mid-transition; new projects use `aem/assets/contenthub/1`. + +**Auth is different from the other AEM surfaces:** Content Hub does *not* use `sharedContext`. Get auth and environment from the `host` namespaces instead (`host.auth.getIMSInfo()`, `host.discovery.getAemHost()` — see Host APIs below). + +### Registration (all three namespaces) + +```js +import { register } from '@adobe/uix-guest'; + +// `let` (not `const`): card/selectionBar onActionClick handlers reference the +// connection AFTER register() resolves, to open a modal via host.modal.openDialog(). +let guestConnection; + +guestConnection = await register({ + id: 'my.company.extension-name', // reverse-domain; must match attach() calls + methods: { + assetDetails: { + getTabPanels() { /* tab panels in the Asset Details Dialog */ }, + }, + card: { + getActionButtons(actionContext) { /* buttons on asset cards + collection tiles */ }, + async onActionClick(resourceType, buttonId, resourceId, actionContext) { /* … */ }, + }, + selectionBar: { + getActionButtons(actionContext) { /* buttons in the bulk-action bar */ }, + async onActionClick(buttonId, assetIds) { /* … */ }, + }, + }, +}); +``` + +Opt into any combination — only implement the namespaces you use, and scaffold one component per namespace (`PanelAssetDetailsExtensionTab.js` for `assetDetails`, `CardActionModal.js` for `card`, `SelectionBarModal.js` for `selectionBar`). `card` and `selectionBar` are gated by the `EXTENSIBILITY_AEM_CONTENTHUB` feature flag; when it is off the host renders no buttons for those surfaces, but `assetDetails` panels still render. + +`app.config.yaml` includes the unified extension point once: + +```yaml +extensions: + aem/assets/contenthub/1: + $include: src/aem-assets-contenthub-1/ext.config.yaml +``` + +### Asset Details (`assetDetails`) + +Adds tab panels to the Asset Details Dialog side rail. Content Hub manages toggling, deep-linking, and header rendering — the extension only provides the panel content via a hash route. + +```js +assetDetails: { + getTabPanels() { + return [ + { + id: 'my-panel', // unique within this extension + title: 'My Panel', // panel header (Content Hub renders it) + tooltip: 'My Panel', // side-rail icon tooltip + icon: 'Extension', // React-Spectrum workflow icon name + contentUrl: '/#asset-details-extension-tab', // hash route — must match a in App.js + }, + ]; + }, +} +``` + +Restrict to specific repos with an allow-list; leave it empty to load for any repo (safe for local dev): + +```js +const allowedRepos = ['delivery-p12345-e167890.adobeaemcloud.com']; +const shouldSkipRegistration = (repo) => allowedRepos.length > 0 && !allowedRepos.includes(repo); +``` + +### Asset Card Actions (`card`) + +Buttons on asset cards (Assets grid / inside a collection / link-share) and on collection tiles. One implementation serves every card surface — the host passes the surface in `actionContext.context`. + +```js +card: { + // actionContext.context: 'assets' | 'collection' | 'share' (asset cards) | 'collections' (collection tiles) + getActionButtons(actionContext) { + return [ + { id: 'my-card-action', label: 'Edit Metadata', icon: 'Edit' }, // card uses `label`, NOT `title` + ]; + }, + // Exact arg order the host uses. Optional (host guards with ?.) — needed to open a modal. + async onActionClick(resourceType, buttonId, resourceId, actionContext) { + // resourceType: 'asset' (cards) | 'collection' (tiles); resourceId: the URN string + await guestConnection.host.modal.openDialog({ + title: 'Edit Metadata', + contentUrl: `/#card-action-modal?resourceId=${encodeURIComponent(resourceId)}&resourceType=${resourceType}`, + type: 'modal', + size: 'M', + }); + }, +} +``` + +Only `id`, `label`, `icon` are read for card buttons. Because `onActionClick` fires *after* `register()` resolves, declare `let guestConnection` so the handler can reference it. + +### Selection Bar / Bulk Actions (`selectionBar`) + +Buttons in the bulk-action bar shown when one or more assets are selected. The signature **differs from `card`**: no `resourceType`, and the click handler receives an **array** of asset IDs. + +```js +selectionBar: { + // actionContext: { context: 'assets'|'collections'|'collection'|'share', + // resourceSelection: { resources: [{ id }, …] } } + getActionButtons(actionContext) { + return [ + { id: 'my-bulk-action', label: 'Bulk Export', icon: 'Download' }, // uses `label`, NOT `title` + ]; + }, + async onActionClick(buttonId, assetIds) { // assetIds: string[] of selected URNs + const ids = encodeURIComponent(JSON.stringify(assetIds || [])); + await guestConnection.host.modal.openDialog({ + title: `Bulk Export (${assetIds.length})`, + contentUrl: `/#selection-bar-modal?assetIds=${ids}`, + type: 'modal', + size: 'M', + }); + }, +} +``` + +The host prefixes selection-bar button ids internally (`ext::`); your code always uses the original `btn.id` — that's what `onActionClick` receives too. + +### Opening a Modal (`modal`) + +Card and selection-bar actions have no panel of their own — they open a modal whose content is another hash route in the same guest app. **Content Hub's `openDialog` takes a single config object** — you never pass `{ id }` (the UIX host auto-injects the extension id on its side). This is the **opposite** of the other AEM surfaces, which use `host.modal.showUrl({ title, url })` + `close()`. Don't cross them. + +```js +// From an onActionClick handler: +await guestConnection.host.modal.openDialog({ + title: 'Dialog title', + contentUrl: '/#card-action-modal?resourceId=…', // pass data via query string… + type: 'modal', // 'modal' | 'fullscreen' + size: 'M', // 'S' | 'M' | 'L' + // payload: { … }, // …or via payload, read with modal.getPayload() +}); +``` + +Inside the modal page (its own iframe route), read the data and reconnect with `attach()`: + +```js +import { attach } from '@adobe/uix-guest'; +import { extensionId } from './Constants'; + +const params = new URLSearchParams(window.location.hash.split('?')[1] || ''); +const resourceId = params.get('resourceId'); // or: const { resourceId } = await connection.host.modal.getPayload(); +const connection = await attach({ id: extensionId }); +await connection.host.modal.closeDialog(); // dismiss +``` + +### React Routing (`App.js`) + +Hash routing — every panel/modal `contentUrl` must match a ``. Keep only the routes for the namespaces you use. + +```js +import { HashRouter as Router, Routes, Route } from 'react-router-dom'; + + + + } /> + } /> + } /> {/* assetDetails */} + } /> {/* card modal */} + } /> {/* selectionBar modal */} + + +``` + +### Host APIs + +All via `guestConnection.host` (from either `register()` or `attach()`); every call returns a Promise. + +```js +const { imsOrg, imsOrgName, accessToken } = await guestConnection.host.auth.getIMSInfo(); +const apiKey = await guestConnection.host.auth.getApiKey(); // never hardcode +const aemHost = await guestConnection.host.discovery.getAemHost(); // "author-p12345-e67890.adobeaemcloud.com" +guestConnection.host.toast.display({ variant: 'positive', message: 'Saved!' }); // neutral|positive|info|negative +const { locale } = await guestConnection.host.i18n.getLocalizationInfo(); +const assetId = await guestConnection.host.assetDetails.getCurrentAsset(); // plain STRING (e.g. "urn:aaid:aem:…") +``` + +### Calling Web Actions from a Panel + +Never call AEM APIs from the browser (CORS blocks them) — route through an App Builder web action: + +```js +// In PanelAssetDetailsExtensionTab.js +const { accessToken, imsOrg } = await guestConnection.host.auth.getIMSInfo(); +const apiKey = await guestConnection.host.auth.getApiKey(); +const aemHost = await guestConnection.host.discovery.getAemHost(); +const assetId = await guestConnection.host.assetDetails.getCurrentAsset(); + +const response = await fetch(actions['aem-assets-contenthub-1/generic'], { // URL from config.json + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assetId, aemHost, apiKey, imsOrg }), +}); +``` + +The web action (`actions/generic/index.js`) makes the authenticated AEM Assets Author API call server-side and returns the result. See the `appbuilder-action-scaffolder` skill for the action itself. + +### Local Development + +```bash +aio app build && aio app run +``` + +Test URL: `https://experience.adobe.com/?devMode=true&ext=https://localhost:9080#/assets/contenthub/` + +First run only: navigate to `https://localhost:9080` and accept the self-signed cert, or the panel stays blank. No `&repo=` needed when `allowedRepos = []`. + +### Common Gotchas (Content Hub) + +1. **`openDialog` is a single object** — passing `{ id }` yourself makes the host retry every 500ms until it times out (`… timed out after 10000ms`, `[object Object] doesn't exist`) while `host.toast` still works, masking the cause. Never pass `{ id }`. +2. **`const guestConnection` breaks card/selectionBar** — their `onActionClick` fires after `register()` resolves; use `let` or the handler closes over `undefined`. +3. **`getCurrentAsset()` returns a STRING**, not `{ id }`. (Assets View's `host.details.getCurrentResourceInfo()` is a different shape — don't mix.) +4. **Card/selectionBar buttons use `label`, not `title`** — a button with only `title` renders blank. (`assetDetails` panels use `title`/`tooltip`.) +5. **`card` vs `selectionBar` signatures differ** — card `onActionClick(resourceType, buttonId, resourceId, actionContext)` (single resource); selectionBar `onActionClick(buttonId, assetIds)` (array, no resourceType). +6. **Buttons missing entirely** — `card`/`selectionBar` are gated by the `EXTENSIBILITY_AEM_CONTENTHUB` flag; asset-details panels still show when it's off. +7. **`beforeUpload` must return `{ proceed, metadata }`** — omitting `metadata` loses it; pass `{ proceed: true, metadata: ctx.metadata }` for a no-op, and always include `message` when blocking. +8. **`attach()` id must match `register()` id** — export `extensionId` from `Constants.js` and import it in both. + +--- + ## Extension Testing & Development ### Local Development @@ -482,3 +714,4 @@ extensions: | CF Editor | `aem/cf-editor/1` | `headerMenu`, `rte` | `contentFragment`, `modal`, `toaster` | | Universal Editor | `aem/universal-editor/1` | `headerMenu` | `modal` | | Assets View | `aem/assets/1` | `actionBar`, `headerMenu` | `modal` | +| Content Hub | `aem/assets/contenthub/1` | `assetDetails`, `card`, `selectionBar` | `auth`, `discovery`, `toast`, `i18n`, `modal` (`openDialog`/`closeDialog`), `assetDetails` |