diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index dba6a1444..00af97966 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.21.0 +version: 1.22.0 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: @@ -55,6 +55,12 @@ build-parameters: - bash -c "cd .. && npm --prefix app/explore install --no-audit --no-fund" - bash -c "cd .. && npm --prefix app/explore run build" - bash -c "cd .. && npx tsx scripts/build-explore-manifest.ts app/explore/dist hugo/data/explore_bundle.json" + # Build app/channel-atlas + emit the bundle manifest into hugo/data/. + # Hugo reads site.Data.channel_atlas_bundle in + # hugo/layouts/channels/atlas.html to inject hashed JS/CSS filenames. + - bash -c "cd .. && npm --prefix app/channel-atlas install --no-audit --no-fund" + - bash -c "cd .. && npm --prefix app/channel-atlas run build" + - bash -c "cd .. && npx tsx scripts/build-channel-atlas-manifest.ts app/channel-atlas/dist hugo/data/channel_atlas_bundle.json" - cp ../xs-security.json . # Prod-only XSUAA descriptor: distinct xsappname (tutorials-prod) + # ' (Prod)'-suffixed role-collection names so the prod xsuaa can coexist @@ -172,7 +178,7 @@ modules: - cp -r ../../hugo/assets ./hugo/assets - cp -r ../../hugo/data ./hugo/data - cp -r ../../hugo/i18n ./hugo/i18n - - bash -c "mkdir -p srv/jobs && mkdir -p srv/handlers && mkdir -p srv/lib/branch && mkdir -p srv/lib/runtime-config && mkdir -p srv/lib/prompts && mkdir -p srv/lib/kg && mkdir -p srv/mcp/prompts && cp ../../srv/lib/branch/condition.js ../../srv/lib/branch/engine.js ../../srv/lib/branch/ranker.js ../../srv/lib/branch/user-state.js ../../srv/lib/branch/loaders.js ../../srv/lib/branch/mission-detail.js ../../srv/lib/branch/slug-key.js ../../srv/lib/branch/decide.js ../../srv/lib/branch/joule-tool.js ../../srv/lib/branch/branch-telemetry.js ../../srv/lib/branch/group-by-alt.js ../../srv/lib/branch/profile-fields.js ../../srv/lib/branch/profile-override.js srv/lib/branch/ && cp ../../srv/lib/runtime-config/kg-settings.js ../../srv/lib/runtime-config/ui-events-settings.js ../../srv/lib/runtime-config/search-settings.js ../../srv/lib/runtime-config/navigator-settings.js ../../srv/lib/runtime-config/display-settings.js ../../srv/lib/runtime-config/tenant-settings.js ../../srv/lib/runtime-config/alert-settings.js srv/lib/runtime-config/ && cp ../../srv/lib/kg/on-demand-enqueue.js ../../srv/lib/kg/on-demand-cosine-rank.js srv/lib/kg/ && cp ../../srv/lib/credstore.js ../../srv/lib/secret-resolver.js ../../srv/lib/content-store.js ../../srv/lib/content-delta-flags.js ../../srv/lib/content-cache-coherence.js ../../srv/lib/edge-cache-headers.js ../../srv/lib/content-publish-session.js ../../srv/lib/resolve-tutorial-author.js ../../srv/lib/_tutorials-table.js ../../srv/lib/catalog-renderer.js ../../srv/lib/catalog-data.js ../../srv/lib/catalog-mission-hierarchy.js ../../srv/lib/chrome-shell.js ../../srv/lib/pipeline-log.js ../../srv/lib/legacy-id.js ../../srv/lib/embedding-pipeline.js ../../srv/lib/step-text-extractor.js ../../srv/lib/embedding-client.js ../../srv/lib/step-vectors.js ../../srv/lib/user-progress.js ../../srv/lib/co-completion.js ../../srv/lib/tutorial-centroid.js ../../srv/lib/tag-label-map.js ../../srv/lib/code-check-tool.js ../../srv/lib/code-check-prompt.js ../../srv/lib/code-check-handler.js ../../srv/lib/code-check-llm.js ../../srv/lib/code-check-step-loader.js ../../srv/lib/code-check-spec-publish.js ../../srv/lib/validate-answer-spec-publish.js ../../srv/lib/category-classifier.js ../../srv/lib/category-classifier-llm.js ../../srv/lib/category-seed-embeddings.js ../../srv/lib/build-catalog-categories.js ../../srv/lib/chat-settings-resolver.js ../../srv/lib/kg-extract.js ../../srv/lib/kg-queries.js ../../srv/lib/kg-projection.js ../../srv/lib/kg-similarity.js ../../srv/lib/kg-cycles.js ../../srv/lib/kg-graph-rebuild.js ../../srv/lib/kg-sparql-client.js ../../srv/lib/kg-merge-pair.js ../../srv/lib/kg-concept-loader.js ../../srv/lib/kg-neighborhood-cache.js ../../srv/lib/kg-neighborhood-merge.js ../../srv/lib/kg-neighborhood-full-helpers.js ../../srv/lib/kg-other-resources-loader.js ../../srv/lib/kg-stamp-meta-text.js ../../srv/lib/kg-tutorial-teaches-map.js ../../srv/lib/kg-resource-type-config.js ../../srv/lib/kg-meta-formatters.js ../../srv/lib/discovery-mission-categories.js ../../srv/lib/external-content-ttl.js ../../srv/lib/recompute-tutorial-progress-bulk-sql.js ../../srv/lib/youtube-fetcher.js ../../srv/lib/homepage-events-merger.js ../../srv/lib/homepage-rss-fetcher.js ../../srv/lib/rss-parse.js ../../srv/lib/community-blogs-fetcher.js ../../srv/lib/community-blog-source-defaults.js ../../srv/lib/community-blogs-classifier.js ../../srv/lib/safe-fetch.js ../../srv/lib/curl-transport.js ../../srv/lib/khoros-transport.js ../../srv/lib/explainer-generator.js ../../srv/lib/_token-cost.js ../../srv/lib/metrics.js ../../srv/lib/alerting.js ../../srv/lib/relevance-classifier.js ../../srv/lib/relevance-seed-embeddings.js ../../srv/lib/relevance-keyword-rules.js ../../srv/lib/canonicalize-link.js ../../srv/lib/detect-language-en.js ../../srv/lib/kg-community-coverage.js ../../srv/lib/page-key-map.js ../../srv/lib/page-fallback.js ../../srv/lib/task-record-submission-id.js ../../srv/lib/image-store.cjs ../../srv/lib/image-ingest.cjs ../../srv/lib/image-source-handler.js ../../srv/lib/img-cdn-fetch.cjs ../../srv/lib/img-cdn-retry.cjs ../../srv/lib/image-warm-utils.js ../../srv/lib/attachment-store.cjs ../../srv/lib/attachment-ingest.cjs ../../srv/lib/attachment-mime.cjs ../../srv/lib/attachment-warm-utils.js ../../srv/lib/attachment-source-handler.js ../../srv/lib/attachment-ingest-handler.js ../../srv/lib/contributors-publish.js ../../srv/lib/validation-rules-publish.js ../../srv/lib/island-manifest.json srv/lib/ && mkdir -p srv/lib/feature-flags && cp ../../srv/lib/feature-flags/db-flags.js ../../srv/lib/feature-flags/registry.js srv/lib/feature-flags/ && cp ../../srv/handlers/categories-after-hooks.js ../../srv/handlers/completion-path-items-altgroup.js srv/handlers/ && mkdir -p srv && cp ../../srv/content-moderation-service.js srv/ && cp ../../srv/jobs/consolidate-concepts-job.js ../../srv/jobs/extract-concepts-job.js ../../srv/jobs/job-lock.js ../../srv/jobs/secret-expiry-check.js ../../srv/jobs/homepage-link-health.js ../../srv/jobs/kg-ondemand-job.js ../../srv/jobs/community-blogs-fetch-job.js ../../srv/jobs/community-blogs-classify-job.js ../../srv/jobs/fetch-news-job.js srv/jobs/ && cp ../../srv/lib/prompts/explainer-verb.md ../../srv/lib/prompts/explainer-shelf.md ../../srv/lib/prompts/explainer-shelf-entry.md ../../srv/lib/prompts/community-blogs-classifier.md srv/lib/prompts/ && cp ../../srv/mcp/prompts/summarize_mission_for_beginner.md ../../srv/mcp/prompts/generate_lab_exercise.md ../../srv/mcp/prompts/explain_concept.md ../../srv/mcp/prompts/suggest_learning_path.md srv/mcp/prompts/" + - bash -c "mkdir -p srv/jobs && mkdir -p srv/handlers && mkdir -p srv/lib/branch && mkdir -p srv/lib/runtime-config && mkdir -p srv/lib/prompts && mkdir -p srv/lib/kg && mkdir -p srv/mcp/prompts && cp ../../srv/lib/branch/condition.js ../../srv/lib/branch/engine.js ../../srv/lib/branch/ranker.js ../../srv/lib/branch/user-state.js ../../srv/lib/branch/loaders.js ../../srv/lib/branch/mission-detail.js ../../srv/lib/branch/slug-key.js ../../srv/lib/branch/decide.js ../../srv/lib/branch/joule-tool.js ../../srv/lib/branch/branch-telemetry.js ../../srv/lib/branch/group-by-alt.js ../../srv/lib/branch/profile-fields.js ../../srv/lib/branch/profile-override.js srv/lib/branch/ && cp ../../srv/lib/runtime-config/kg-settings.js ../../srv/lib/runtime-config/ui-events-settings.js ../../srv/lib/runtime-config/search-settings.js ../../srv/lib/runtime-config/navigator-settings.js ../../srv/lib/runtime-config/display-settings.js ../../srv/lib/runtime-config/tenant-settings.js ../../srv/lib/runtime-config/alert-settings.js srv/lib/runtime-config/ && cp ../../srv/lib/kg/on-demand-enqueue.js ../../srv/lib/kg/on-demand-cosine-rank.js srv/lib/kg/ && cp ../../srv/lib/credstore.js ../../srv/lib/secret-resolver.js ../../srv/lib/content-store.js ../../srv/lib/content-delta-flags.js ../../srv/lib/content-cache-coherence.js ../../srv/lib/edge-cache-headers.js ../../srv/lib/content-publish-session.js ../../srv/lib/resolve-tutorial-author.js ../../srv/lib/_tutorials-table.js ../../srv/lib/catalog-renderer.js ../../srv/lib/catalog-data.js ../../srv/lib/catalog-mission-hierarchy.js ../../srv/lib/chrome-shell.js ../../srv/lib/pipeline-log.js ../../srv/lib/legacy-id.js ../../srv/lib/embedding-pipeline.js ../../srv/lib/step-text-extractor.js ../../srv/lib/embedding-client.js ../../srv/lib/step-vectors.js ../../srv/lib/user-progress.js ../../srv/lib/co-completion.js ../../srv/lib/tutorial-centroid.js ../../srv/lib/tag-label-map.js ../../srv/lib/code-check-tool.js ../../srv/lib/code-check-prompt.js ../../srv/lib/code-check-handler.js ../../srv/lib/code-check-llm.js ../../srv/lib/code-check-step-loader.js ../../srv/lib/code-check-spec-publish.js ../../srv/lib/validate-answer-spec-publish.js ../../srv/lib/category-classifier.js ../../srv/lib/category-classifier-llm.js ../../srv/lib/category-seed-embeddings.js ../../srv/lib/build-catalog-categories.js ../../srv/lib/chat-settings-resolver.js ../../srv/lib/kg-extract.js ../../srv/lib/kg-queries.js ../../srv/lib/kg-projection.js ../../srv/lib/kg-similarity.js ../../srv/lib/kg-cycles.js ../../srv/lib/kg-graph-rebuild.js ../../srv/lib/kg-sparql-client.js ../../srv/lib/kg-merge-pair.js ../../srv/lib/kg-concept-loader.js ../../srv/lib/kg-neighborhood-cache.js ../../srv/lib/kg-neighborhood-merge.js ../../srv/lib/kg-neighborhood-full-helpers.js ../../srv/lib/kg-other-resources-loader.js ../../srv/lib/kg-stamp-meta-text.js ../../srv/lib/kg-tutorial-teaches-map.js ../../srv/lib/kg-resource-type-config.js ../../srv/lib/kg-meta-formatters.js ../../srv/lib/discovery-mission-categories.js ../../srv/lib/external-content-ttl.js ../../srv/lib/recompute-tutorial-progress-bulk-sql.js ../../srv/lib/youtube-fetcher.js ../../srv/lib/homepage-events-merger.js ../../srv/lib/homepage-rss-fetcher.js ../../srv/lib/rss-parse.js ../../srv/lib/community-blogs-fetcher.js ../../srv/lib/community-blog-source-defaults.js ../../srv/lib/community-blogs-classifier.js ../../srv/lib/safe-fetch.js ../../srv/lib/curl-transport.js ../../srv/lib/khoros-transport.js ../../srv/lib/explainer-generator.js ../../srv/lib/_token-cost.js ../../srv/lib/metrics.js ../../srv/lib/alerting.js ../../srv/lib/relevance-classifier.js ../../srv/lib/relevance-seed-embeddings.js ../../srv/lib/relevance-keyword-rules.js ../../srv/lib/canonicalize-link.js ../../srv/lib/detect-language-en.js ../../srv/lib/kg-community-coverage.js ../../srv/lib/page-key-map.js ../../srv/lib/page-fallback.js ../../srv/lib/task-record-submission-id.js ../../srv/lib/image-store.cjs ../../srv/lib/image-ingest.cjs ../../srv/lib/image-source-handler.js ../../srv/lib/img-cdn-fetch.cjs ../../srv/lib/img-cdn-retry.cjs ../../srv/lib/image-warm-utils.js ../../srv/lib/attachment-store.cjs ../../srv/lib/attachment-ingest.cjs ../../srv/lib/attachment-mime.cjs ../../srv/lib/attachment-warm-utils.js ../../srv/lib/attachment-source-handler.js ../../srv/lib/attachment-ingest-handler.js ../../srv/lib/contributors-publish.js ../../srv/lib/validation-rules-publish.js ../../srv/lib/topics-query.js ../../srv/lib/topic-slug.js ../../srv/lib/tag-md-format.js ../../srv/lib/publish-channels.js ../../srv/lib/media-diet-picks.js ../../srv/lib/media-diet-export.js ../../srv/lib/build-channel-detail.js ../../srv/lib/channel-detail-render.js ../../srv/lib/build-channel-atlas.js ../../srv/lib/island-manifest.json srv/lib/ && mkdir -p srv/lib/channels && cp ../../srv/lib/channels/normalize.cjs srv/lib/channels/ && mkdir -p srv/lib/feature-flags && cp ../../srv/lib/feature-flags/db-flags.js ../../srv/lib/feature-flags/registry.js srv/lib/feature-flags/ && cp ../../srv/handlers/categories-after-hooks.js ../../srv/handlers/completion-path-items-altgroup.js srv/handlers/ && mkdir -p srv && cp ../../srv/content-moderation-service.js srv/ && cp ../../srv/jobs/consolidate-concepts-job.js ../../srv/jobs/extract-concepts-job.js ../../srv/jobs/job-lock.js ../../srv/jobs/secret-expiry-check.js ../../srv/jobs/homepage-link-health.js ../../srv/jobs/kg-ondemand-job.js ../../srv/jobs/community-blogs-fetch-job.js ../../srv/jobs/community-blogs-classify-job.js ../../srv/jobs/fetch-news-job.js srv/jobs/ && cp ../../srv/lib/prompts/explainer-verb.md ../../srv/lib/prompts/explainer-shelf.md ../../srv/lib/prompts/explainer-shelf-entry.md ../../srv/lib/prompts/community-blogs-classifier.md srv/lib/prompts/ && cp ../../srv/mcp/prompts/summarize_mission_for_beginner.md ../../srv/mcp/prompts/generate_lab_exercise.md ../../srv/mcp/prompts/explain_concept.md ../../srv/mcp/prompts/suggest_learning_path.md srv/mcp/prompts/" - bash -c "node -e \"const p=require('./package.json'); p.dependencies=Object.assign(p.dependencies||{},{cheerio:'^1.2.0','@sap-ai-sdk/foundation-models':'^2.10.0'}); require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2));\"" properties: EXPOSE_CAP_UI: false @@ -205,7 +211,7 @@ modules: # Wipe each static dir BEFORE cp'ing fresh content. Hugo public, qa, admin-ui, # analytics-ui, scanner-ui all need this; everything not under static/ here # (e.g. node_modules, package.json) is preserved. - - rm -rf static/admin-ui static/analytics-ui static/scanner-ui static/explore-ui static/data-inspector-ui static/qa + - rm -rf static/admin-ui static/analytics-ui static/scanner-ui static/explore-ui static/channel-atlas-ui static/data-inspector-ui static/qa # CAP-sourced Hugo sections (concepts, developer-advocates, browse) are # generated at build time from /build/* endpoints. Because `cp -r` is # union (see comment above), a build against a backend with FEWER rows @@ -230,6 +236,8 @@ modules: - cp -r ../app/analytics-explorer/dist/. static/analytics-ui/ - mkdir -p static/explore-ui - cp -r ../app/explore/dist/. static/explore-ui/ + - mkdir -p static/channel-atlas-ui + - cp -r ../app/channel-atlas/dist/. static/channel-atlas-ui/ - cp -r ../app/scanner/webapp static/scanner-ui # Display App (event-monitor dashboard) — Vue 3 + Vite, base '/display-app/'. # Served from static/display-app via the /display-app/ approuter route. This diff --git a/.github/workflows/rebuild-content.yml b/.github/workflows/rebuild-content.yml index e841a9ad6..108cd223e 100644 --- a/.github/workflows/rebuild-content.yml +++ b/.github/workflows/rebuild-content.yml @@ -650,6 +650,36 @@ jobs: - name: Build Hugo site run: /tmp/hugo --source hugo --minify + # Preserve /sitemap.xml across catalog-only rebuilds (same wipe class as + # /browse/ + /authors/ above; reported 2026-09-03 by the Intelligent Search + # Data Crawling team). /sitemap.xml is served from the HANA `page-sitemap.xml` + # blob and publish-content.ts republishes it on every non-slug rebuild. + # catalog-only skips "Fetch tutorials", so the Hugo build just above bakes a + # sitemap with NO /tutorials/ URLs — publishing that would wipe the live + # sitemap to ~180 links. The sitemap can't be regenerated here (it needs the + # ~1.4k tutorial pages the skipped GitHub fetch provides), so — UNLIKE the + # browse/author preserve steps, which re-hydrate build INPUTS before the + # build — this runs AFTER the build and overwrites the freshly-baked sitemap + # with the one the approuter is CURRENTLY serving. Fails the run (non-zero) + # rather than ship a wiped sitemap if the deployed one is unreadable or names + # zero tutorials. Not needed in `full` (regenerates it) or `slug-targeted` + # (page-sitemap.xml is not republished). + - name: Preserve sitemap.xml for catalog-only rebuild + if: ${{ steps.mode.outputs.effective_mode == 'catalog-only' }} + env: + APPROUTER_URL: ${{ steps.url.outputs.approuter_url }} + run: npx tsx scripts/seed-sitemap-from-deployed.ts + + # Fail-closed net for the sitemap wipe class. Runs in the modes that + # republish the page-sitemap.xml blob (full + catalog-only; slug-targeted + # does not touch it). Fails BEFORE the HANA publish if the sitemap about to + # be published names zero /tutorials/ URLs — whatever the cause (broken + # preserve step, a future fetch-skipping mode, a bad Hugo build). A red run + # is recoverable (re-run mode=full); a silently wiped sitemap is the incident. + - name: Guard - sitemap includes tutorial URLs + if: ${{ steps.mode.outputs.effective_mode != 'slug-targeted' }} + run: node scripts/check-sitemap-tutorials.cjs --hugo-dir hugo/public + # [#1657] Restore CDS syntax highlighting to the runtime-push path. Hugo's # render-codeblock.html maps ```cds → the Chroma SQL lexer as a PLACEHOLDER # (Chroma has no CDS lexer) and tags the wrapper `data-lang=cds`; the SQL diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 52ca9a88b..fcdfba26d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -11,10 +11,10 @@ on: # never lands unnoticed. Closes the PR-gate half of #961. # # The vitest `unit` project uses in-memory SQLite and does not need a HANA -# binding or CF login. `npm run setup` installs the nested `hugo-apps` and -# `app/explore` workspaces and rebuilds `better-sqlite3` — both are required -# by the unit suite and are otherwise silently skipped by the org-wide -# `npm config ignore-scripts=true` policy. +# binding or CF login. `npm run setup` installs the nested `hugo-apps`, +# `app/explore`, and `app/channel-atlas` workspaces and rebuilds +# `better-sqlite3` — all are required by the unit suite and are otherwise +# silently skipped by the org-wide `npm config ignore-scripts=true` policy. jobs: unit: @@ -42,7 +42,7 @@ jobs: run: npm ci --no-audit --no-fund env: NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }} - - name: Setup nested workspaces (hugo-apps, app/explore) + rebuild native bindings + - name: Setup nested workspaces (hugo-apps, app/explore, app/channel-atlas) + rebuild native bindings run: npm run setup - name: Run unit tests run: npm test diff --git a/.superpowers/sdd/task-7-fix-report.md b/.superpowers/sdd/task-7-fix-report.md deleted file mode 100644 index 0d81c278f..000000000 --- a/.superpowers/sdd/task-7-fix-report.md +++ /dev/null @@ -1,70 +0,0 @@ -# Task 7 Fix Report — Final review rollout-safety findings - -**Status:** COMPLETE - ---- - -## Finding 1 (Important) — Admin secret rotation did not flush App installation-token cache - -**Files changed:** -- `srv/admin-service.js` - -**Import added** (line 19): -```javascript -import { invalidateInstallationToken } from './lib/github-app-token.js'; -``` - -**Logic added** after each `invalidateSecret(row.key)` in all three handlers -(`setSecretValue` ~line 2315, `rotateSecretValue` ~line 2373, `clearSecretValue` ~line 2417): -```javascript -if (row.key === 'TUTORIALS_APP_ID' || row.key === 'TUTORIALS_APP_INSTALLATION_ID' || row.key === 'TUTORIALS_APP_PRIVATE_KEY') { - invalidateInstallationToken(); -} -``` - -This ensures rotating any GitHub App credential via `/admin-ui/#secrets` immediately -clears the ~55-min cached installation token so the next caller mints fresh. - ---- - -## Finding 2 (Minor) — Workflow template `||` fallback comment was misleading - -**File changed:** -- `.github/workflows/notify-qa.yml.template` - -**Comment appended** inside the existing block above the `Generate GitHub App token` step: -``` -# NOTE: the || fallback below only engages if App-token GENERATION fails — -# NOT if dispatch is unauthorized. Grant the App Contents:write on -# tutorials-ims BEFORE setting USE_GITHUB_APP=true here (see runbook). -``` - -**YAML validation:** -``` -$ yq '.' .github/workflows/notify-qa.yml.template > /dev/null && echo "valid YAML" -valid YAML -``` - ---- - -## Test result - -``` -npx vitest run test/unit/admin-secret-value-handlers.test.js - -Test Files 1 passed (1) - Tests 14 passed (14) - Duration 9.09s -``` - -Two new tests added in `test/unit/admin-secret-value-handlers.test.js`: -- `setSecretValue on TUTORIALS_APP_PRIVATE_KEY flushes installation-token cache` — primes the `globalThis[Symbol.for('...github-app-token')]` singleton to a known value, calls the handler, asserts the token is null afterward. -- `setSecretValue on a non-App key (SMTP_PASS) does NOT flush installation-token cache` — asserts the primed token is unchanged after handling a non-App key, preventing false-positive flushes. - -All 14 tests pass (12 pre-existing + 2 new). - ---- - -## Commit SHA - -(see commit below) diff --git a/CLAUDE.md b/CLAUDE.md index f9197520d..f9281be8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,3 +133,4 @@ The load-bearing few. **Full detail for every relocated item → [tutorials-ims- - **User-facing UI changes want a committed e2e spec** — advisory PR nudge on `app/**`/`hugo/**` changes; real coverage runs in the post-DEV-deploy `e2e` job. Ref: [e2e-coverage-pattern.md](docs/developers/reference/e2e-coverage-pattern.md). - **`test:e2e` is post-deploy only, not on PRs** — self-skips without `SMOKE_BASE_URL`. Served tutorials render `
`+`

`, NOT `
`. Runbook: `test/e2e/README.md`. - **Freshness detector grounding needs the corpus-embedding backfill** — until `srv/jobs/freshness-corpus-embedding-job.js` runs, every API-obsolescence claim degrades to `confidence: Low`. Tutorial source from `ContentFiles.sourceContent` via `getTutorialSource(slug)`, NOT `Steps.description`. → gotchas.md "Freshness detector". +- **External channels subsystem** — `Channels` entity (`db/channels.cds`) is the source of truth; re-ingest via `npm run seed-channels`; directory at `/channels` (baked by `fetch-channels` in `build:all`); verb-lane fill via `npm run promote-channels`; community items never land in `START_HERE`. → [channels.md](docs/developers/reference/channels.md). diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 7d5a38e86..0a6b45d3b 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -46,6 +46,8 @@ annotate AdminService.Events with { }; startDate @Common.Label: 'Start Date'; endDate @Common.Label: 'End Date'; + description @Common.Label: 'Description' @UI.MultiLineText; // Shown on app-space hero (#2133) + logoUpdatedAt @Common.Label: 'Logo Updated' @Common.FieldControl: #ReadOnly; timeZone @Common.Label: 'Time Zone' @Common.ValueList: { CollectionPath: 'TimeZones', @@ -93,10 +95,12 @@ annotate AdminService.Events with @UI: { ], FieldGroup#General: { Data: [ { Value: name }, + { Value: description }, { Value: eventType }, { Value: startDate }, { Value: endDate }, - { Value: timeZone } + { Value: timeZone }, + { Value: logoUpdatedAt } ]} }; @@ -4488,3 +4492,164 @@ annotate AdminService.Tutorials with @( ] } ); + +// --- External Channels (P0-P4) --------------------------------------------- +// Draft enablement lives in srv/admin-service.cds (Channels / ChannelCollections +// / ChannelTopicMap are @odata.draft.enabled there). These blocks give the FE +// List Reports their columns + Object Page facets so the tables render and the +// Create/Edit toolbar appears. Array-typed source fields (relatedUrls, aliases, +// focusAreas, tags) are deliberately omitted — FE V4 does not render +// `array of String` cleanly in tables/OP; curators edit them via re-ingest. +annotate AdminService.Channels with { + sourceId @Common.Label: 'Source ID' @Common.FieldControl: #ReadOnly; + name @Common.Label: 'Name' @mandatory; + url @Common.Label: 'URL' @mandatory; + purpose @Common.Label: 'Purpose' @UI.MultiLineText; + notes @Common.Label: 'Notes' @UI.MultiLineText; + ownerName @Common.Label: 'Owner'; + ownerType @Common.Label: 'Owner Type'; + isSapOwned @Common.Label: 'SAP-owned'; + category @Common.Label: 'Category'; + subcategory @Common.Label: 'Subcategory'; + platform @Common.Label: 'Platform'; + status @Common.Label: 'Status'; + updateFrequency@Common.Label: 'Update Frequency'; + githubStars @Common.Label: 'GitHub Stars'; + subscribers @Common.Label: 'Subscribers'; + isPublished @Common.Label: 'Published'; + isFeatured @Common.Label: 'Featured'; + editorialNote @Common.Label: 'Editorial Note' @UI.MultiLineText; + linkStatus @Common.Label: 'Link Status' @Common.FieldControl: #ReadOnly; + linkStatusOverride @Common.Label: 'Link Status Override'; + lastChecked @Common.Label: 'Last Checked' @Common.FieldControl: #ReadOnly; + ingestBatch @Common.Label: 'Ingest Batch' @Common.FieldControl: #ReadOnly; +}; + +annotate AdminService.Channels with @UI: { + HeaderInfo: { + TypeName: 'Channel', TypeNamePlural: 'Channels', + Title: { Value: name }, + Description: { Value: ownerName } + }, + SelectionFields: [ name, category, ownerType, status, isPublished, isFeatured ], + LineItem: [ + { Value: name }, + { Value: category }, + { Value: ownerType }, + { Value: status }, + { Value: platform }, + { Value: isPublished }, + { Value: isFeatured }, + { Value: linkStatus } + ], + Facets: [ + { $Type: 'UI.ReferenceFacet', Target: '@UI.FieldGroup#General', Label: 'General' }, + { $Type: 'UI.ReferenceFacet', Target: '@UI.FieldGroup#Curation', Label: 'Curation' } + ], + FieldGroup#General: { Data: [ + { Value: sourceId }, + { Value: name }, + { Value: url }, + { Value: purpose }, + { Value: notes }, + { Value: ownerName }, + { Value: ownerType }, + { Value: isSapOwned }, + { Value: category }, + { Value: subcategory }, + { Value: platform }, + { Value: status }, + { Value: updateFrequency }, + { Value: githubStars }, + { Value: subscribers } + ]}, + FieldGroup#Curation: { Data: [ + { Value: isPublished }, + { Value: isFeatured }, + { Value: editorialNote }, + { Value: linkStatus }, + { Value: linkStatusOverride }, + { Value: lastChecked }, + { Value: ingestBatch } + ]} +}; + +annotate AdminService.ChannelCollections with { + slug @Common.Label: 'Slug' @mandatory; + title @Common.Label: 'Title' @mandatory; + intro @Common.Label: 'Introduction' @UI.MultiLineText; + sortOrder @Common.Label: 'Sort Order'; + isPublished @Common.Label: 'Published'; + authoringStatus @Common.Label: 'Authoring Status'; +}; + +annotate AdminService.ChannelCollections with @UI: { + HeaderInfo: { + TypeName: 'Channel Collection', TypeNamePlural: 'Channel Collections', + Title: { Value: title }, + Description: { Value: slug } + }, + SelectionFields: [ title, isPublished, authoringStatus ], + LineItem: [ + { Value: title }, + { Value: slug }, + { Value: sortOrder }, + { Value: isPublished }, + { Value: authoringStatus } + ], + Facets: [ + { $Type: 'UI.ReferenceFacet', Target: '@UI.FieldGroup#General', Label: 'General' }, + { $Type: 'UI.ReferenceFacet', Target: 'items/@UI.LineItem', Label: 'Items' } + ], + FieldGroup#General: { Data: [ + { Value: slug }, + { Value: title }, + { Value: intro }, + { Value: sortOrder }, + { Value: isPublished }, + { Value: authoringStatus } + ]} +}; + +annotate AdminService.ChannelCollectionItems with { + channel @Common.Label: 'Channel' @Common.Text: channel.name @Common.TextArrangement: #TextOnly; + sortOrder @Common.Label: 'Sort Order'; + blurb @Common.Label: 'Blurb'; +}; + +annotate AdminService.ChannelCollectionItems with @UI.LineItem: [ + { Value: channel_ID, Label: 'Channel' }, + { Value: sortOrder }, + { Value: blurb } +]; + +annotate AdminService.ChannelTopicMap with { + topicTag @Common.Label: 'Topic Tag' @mandatory; + relevance @Common.Label: 'Relevance'; + authoringStatus @Common.Label: 'Authoring Status'; + channel @Common.Label: 'Channel' @Common.Text: channel.name @Common.TextArrangement: #TextOnly; +}; + +annotate AdminService.ChannelTopicMap with @UI: { + HeaderInfo: { + TypeName: 'Channel Topic Mapping', TypeNamePlural: 'Channel Topic Mappings', + Title: { Value: topicTag }, + Description: { Value: authoringStatus } + }, + SelectionFields: [ topicTag, authoringStatus ], + LineItem: [ + { Value: channel_ID, Label: 'Channel' }, + { Value: topicTag }, + { Value: relevance }, + { Value: authoringStatus } + ], + Facets: [ + { $Type: 'UI.ReferenceFacet', Target: '@UI.FieldGroup#General', Label: 'General' } + ], + FieldGroup#General: { Data: [ + { Value: channel_ID, Label: 'Channel' }, + { Value: topicTag }, + { Value: relevance }, + { Value: authoringStatus } + ]} +}; diff --git a/app/admin-shell/scripts/admin-shell-overrides.js b/app/admin-shell/scripts/admin-shell-overrides.js index fcd565304..afcc3ed55 100644 --- a/app/admin-shell/scripts/admin-shell-overrides.js +++ b/app/admin-shell/scripts/admin-shell-overrides.js @@ -58,6 +58,8 @@ module.exports = { 'devtoberfest', 'devtoberfestSignups', 'analytics', + 'tutorial-engagement', + 'tutorial-completions', 'joule', 'knowledgeGraph', 'kgCommunities', @@ -80,7 +82,11 @@ module.exports = { 'video-rotation', 'pats', 'featureFlags', - 'petoberfest' + 'petoberfest', + 'channels', + 'channel-collections', + 'channel-topic-map', + 'channel-submissions' ], // @@ -138,6 +144,8 @@ module.exports = { devtoberfest: 'dt', devtoberfestSignups: 'dsg', analytics: 'an', + 'tutorial-engagement': 'teg', + 'tutorial-completions': 'tcp', joule: 'jo', knowledgeGraph: 'kg', kgCommunities: 'kc', @@ -161,7 +169,11 @@ module.exports = { 'content-moderation': 'cm', pats: 'pt', featureFlags: 'ffl', - petoberfest: 'pb' + petoberfest: 'pb', + channels: 'ch', + 'channel-collections': 'cc', + 'channel-topic-map': 'ctm', + 'channel-submissions': 'csub' }, // diff --git a/app/admin-shell/webapp/Component.js b/app/admin-shell/webapp/Component.js index 09c76104b..187fc5816 100644 --- a/app/admin-shell/webapp/Component.js +++ b/app/admin-shell/webapp/Component.js @@ -19,6 +19,9 @@ sap.ui.define([ this._initTheme(); this._initNavModel(); + // Guarantee every headless Fiori Elements app can navigate list -> object. + // Attached before initialize() so the very first route match is covered. + this.getRouter().attachRouteMatched(this._ensureFeInnerAppState, this); this.getRouter().initialize(); }, @@ -313,6 +316,68 @@ sap.ui.define([ oHashChanger.setHash(sShellRoute); }, + // --- Fiori Elements list -> object navigation -------------------------- + // The admin apps run headless inside this shell, which installs a minimal + // fake `sap.ushell.Container` (so FE thinks it is inside a launchpad and + // renders no standalone shell chrome of its own). A side effect of that FLP + // detection is that FE builds a NavigationService WITHOUT + // `storeInnerAppStateAsync` — it expects the (real) launchpad to persist + // inner-app state. Our fake container does not, so the first list-row click + // throws `i.storeInnerAppStateAsync is not a function` and navigation to the + // ObjectPage silently fails (this is why channel/tag rows "couldn't open the + // Object page"). Enhancing the fake CrossApplicationNavigation with app-state + // factory methods does NOT help — FE fixes the service shape purely on + // ushell.Container presence, before those methods are consulted. + // + // Fix: after each route match, wrap `getNavigationService()` on every headless + // admin FE component so the returned service always carries a working + // in-memory `storeInnerAppStateAsync` (and, defensively, `replaceInnerAppStateKey`). + // This mirrors what FE supplies in true standalone mode. Idempotent per component. + _ensureFeInnerAppState: function () { + var Component = sap.ui.require("sap/ui/core/Component"); + if (!Component || !Component.registry) return; + var mComponents = Component.registry.all(); + Object.keys(mComponents).forEach(function (sId) { + var oComp = mComponents[sId]; + if (!oComp || oComp._bInnerAppStatePatched) return; + var sName = oComp.getMetadata && oComp.getMetadata().getName(); + // Only the headless FE apps expose getNavigationService(); the shell + // component (a plain UIComponent) does not, so the method check is enough, + // but scope by namespace to be explicit and future-proof. + if (!sName || sName.indexOf("sap.tutorials.admin.") !== 0) return; + if (typeof oComp.getNavigationService !== "function") return; + this._wrapNavigationService(oComp); + oComp._bInnerAppStatePatched = true; + }.bind(this)); + }, + + _wrapNavigationService: function (oComp) { + var fnOriginal = oComp.getNavigationService.bind(oComp); + oComp.getNavigationService = function () { + var oNav = fnOriginal(); + if (oNav && typeof oNav.storeInnerAppStateAsync !== "function") { + oNav.storeInnerAppStateAsync = function (oAppStateData) { + // Standalone no-op app-state store: keep the payload in memory under a + // synthetic key and hand the key back, exactly as FE expects. + var sKey = "ias-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + oNav.__innerAppStates = oNav.__innerAppStates || {}; + oNav.__innerAppStates[sKey] = oAppStateData; + return Promise.resolve(sKey); + }; + } + if (oNav && typeof oNav.replaceInnerAppStateKey !== "function") { + oNav.replaceInnerAppStateKey = function (sHash, sKey) { + if (!sHash) return sHash; + if (/sap-iapp-state=/.test(sHash)) { + return sHash.replace(/sap-iapp-state=[^&/]*/, "sap-iapp-state=" + sKey); + } + return sHash + (sHash.indexOf("?") >= 0 ? "&" : "?") + "sap-iapp-state=" + sKey; + }; + } + return oNav; + }; + }, + _getShellUIServiceInstance: function () { if (!this._oShellUIService) { var oViewModel = this._oShellViewModel; diff --git a/app/admin-shell/webapp/controller/Shell.controller.js b/app/admin-shell/webapp/controller/Shell.controller.js index 570de98ae..d22d3fb5b 100644 --- a/app/admin-shell/webapp/controller/Shell.controller.js +++ b/app/admin-shell/webapp/controller/Shell.controller.js @@ -33,6 +33,8 @@ sap.ui.define([ devtoberfestSignups: "devtoberfestSignups", board: "board", analytics: "analytics", + tutorialEngagement: "tutorialEngagement", + tutorialCompletions: "tutorialCompletions", statistics: "statistics", metrics: "metrics", joule: "joule", @@ -60,7 +62,11 @@ sap.ui.define([ pats: "pats", petoberfest: "petoberfest", petoberfestContests: "petoberfestContests", - topicClusters: "topicClusters" + topicClusters: "topicClusters", + channels: "channels", + channelCollections: "channelCollections", + channelTopicMap: "channelTopicMap", + channelSubmissions: "channelSubmissions" }; var NAV_KEY_TO_TITLE = { @@ -90,6 +96,8 @@ sap.ui.define([ devtoberfestSignups: "Devtoberfest Signups", board: "Board", analytics: "Completion analytics", + tutorialEngagement: "Tutorial Engagement", + tutorialCompletions: "Tutorial Completions", statistics: "Statistics", metrics: "Observability", joule: "Joule Settings", @@ -117,7 +125,11 @@ sap.ui.define([ pats: "Personal Access Tokens", petoberfest: "Pet Photo Moderation", petoberfestContests: "Petoberfest Contests", - topicClusters: "Topic Clusters" + topicClusters: "Topic Clusters", + channels: "Channels", + channelCollections: "Channel Collections", + channelTopicMap: "Channel Topic Map", + channelSubmissions: "Channel Submissions" }; return Controller.extend("sap.tutorials.admin.shell.controller.Shell", { diff --git a/app/admin-shell/webapp/manifest.json b/app/admin-shell/webapp/manifest.json index b126c0e4b..02a9b84f6 100644 --- a/app/admin-shell/webapp/manifest.json +++ b/app/admin-shell/webapp/manifest.json @@ -71,6 +71,8 @@ "sap.tutorials.admin.devtoberfest": "./components/devtoberfest", "sap.tutorials.admin.devtoberfestSignups": "./components/devtoberfestSignups", "sap.tutorials.admin.analytics": "./components/analytics", + "sap.tutorials.admin.tutorialEngagement": "./components/tutorial-engagement", + "sap.tutorials.admin.tutorialCompletions": "./components/tutorial-completions", "sap.tutorials.admin.joule": "./components/joule", "sap.tutorials.admin.knowledgeGraph": "./components/knowledgeGraph", "sap.tutorials.admin.kgCommunities": "./components/kgCommunities", @@ -93,7 +95,11 @@ "sap.tutorials.admin.videoRotation": "./components/video-rotation", "sap.tutorials.admin.pats": "./components/pats", "sap.tutorials.admin.featureFlags": "./components/featureFlags", - "sap.tutorials.admin.petoberfest": "./components/petoberfest" + "sap.tutorials.admin.petoberfest": "./components/petoberfest", + "sap.tutorials.admin.channels": "./components/channels", + "sap.tutorials.admin.channelCollections": "./components/channel-collections", + "sap.tutorials.admin.channelTopicMap": "./components/channel-topic-map", + "sap.tutorials.admin.channelSubmissions": "./components/channel-submissions" }, "models": { "admin": { @@ -242,6 +248,18 @@ "componentData": {}, "lazy": true }, + "tutorialEngagementComponent": { + "name": "sap.tutorials.admin.tutorialEngagement", + "settings": {}, + "componentData": {}, + "lazy": true + }, + "tutorialCompletionsComponent": { + "name": "sap.tutorials.admin.tutorialCompletions", + "settings": {}, + "componentData": {}, + "lazy": true + }, "jouleSettingsComponent": { "name": "sap.tutorials.admin.joule", "settings": {}, @@ -379,6 +397,30 @@ "settings": {}, "componentData": {}, "lazy": true + }, + "channelsComponent": { + "name": "sap.tutorials.admin.channels", + "settings": {}, + "componentData": {}, + "lazy": true + }, + "channelCollectionsComponent": { + "name": "sap.tutorials.admin.channelCollections", + "settings": {}, + "componentData": {}, + "lazy": true + }, + "channelTopicMapComponent": { + "name": "sap.tutorials.admin.channelTopicMap", + "settings": {}, + "componentData": {}, + "lazy": true + }, + "channelSubmissionsComponent": { + "name": "sap.tutorials.admin.channelSubmissions", + "settings": {}, + "componentData": {}, + "lazy": true } }, "services": { @@ -629,6 +671,26 @@ } ] }, + { + "name": "tutorialEngagement", + "pattern": "tutorial-engagement", + "target": [ + { + "name": "tutorialEngagementTarget", + "prefix": "teg" + } + ] + }, + { + "name": "tutorialCompletions", + "pattern": "tutorial-completions", + "target": [ + { + "name": "tutorialCompletionsTarget", + "prefix": "tcp" + } + ] + }, { "name": "joule", "pattern": "joule", @@ -889,6 +951,46 @@ } ] }, + { + "name": "channels", + "pattern": "channels", + "target": [ + { + "name": "channelsTarget", + "prefix": "ch" + } + ] + }, + { + "name": "channelCollections", + "pattern": "channel-collections", + "target": [ + { + "name": "channelCollectionsTarget", + "prefix": "cc" + } + ] + }, + { + "name": "channelTopicMap", + "pattern": "channel-topic-map", + "target": [ + { + "name": "channelTopicMapTarget", + "prefix": "ctm" + } + ] + }, + { + "name": "channelSubmissions", + "pattern": "channel-submissions", + "target": [ + { + "name": "channelSubmissionsTarget", + "prefix": "csub" + } + ] + }, { "name": "feedbackDashboard", "pattern": "feedback/dashboard", @@ -1071,6 +1173,20 @@ "viewLevel": 1, "prefix": "an" }, + "tutorialEngagementTarget": { + "type": "Component", + "usage": "tutorialEngagementComponent", + "id": "tutorialEngagementTarget", + "viewLevel": 1, + "prefix": "teg" + }, + "tutorialCompletionsTarget": { + "type": "Component", + "usage": "tutorialCompletionsComponent", + "id": "tutorialCompletionsTarget", + "viewLevel": 1, + "prefix": "tcp" + }, "jouleSettingsTarget": { "type": "Component", "usage": "jouleSettingsComponent", @@ -1232,6 +1348,34 @@ "viewLevel": 1, "prefix": "pb" }, + "channelsTarget": { + "type": "Component", + "usage": "channelsComponent", + "id": "channelsTarget", + "viewLevel": 1, + "prefix": "ch" + }, + "channelCollectionsTarget": { + "type": "Component", + "usage": "channelCollectionsComponent", + "id": "channelCollectionsTarget", + "viewLevel": 1, + "prefix": "cc" + }, + "channelTopicMapTarget": { + "type": "Component", + "usage": "channelTopicMapComponent", + "id": "channelTopicMapTarget", + "viewLevel": 1, + "prefix": "ctm" + }, + "channelSubmissionsTarget": { + "type": "Component", + "usage": "channelSubmissionsComponent", + "id": "channelSubmissionsTarget", + "viewLevel": 1, + "prefix": "csub" + }, "feedbackDashboardTarget": { "viewName": "TutorialFeedbackDashboard", "viewLevel": 1 diff --git a/app/admin-shell/webapp/model/navigation.json b/app/admin-shell/webapp/model/navigation.json index 94b926da4..475ed49ca 100644 --- a/app/admin-shell/webapp/model/navigation.json +++ b/app/admin-shell/webapp/model/navigation.json @@ -22,6 +22,10 @@ { "key": "concepts", "title": "Concepts" }, { "key": "advocates", "title": "Advocates" }, { "key": "alerts", "title": "Alerts" }, + { "key": "channels", "title": "Channels" }, + { "key": "channelCollections", "title": "Channel Collections" }, + { "key": "channelTopicMap", "title": "Channel Topic Map" }, + { "key": "channelSubmissions", "title": "Channel Submissions" }, { "key": "operations", "title": "Featured Tasks" } ] }, @@ -66,6 +70,9 @@ "items": [ { "key": "analyticsExternal", "title": "Analytics", "href": "/analytics-ui/", "target": "_self", "requiredScope": "Tutorial.Author" }, { "key": "analytics", "title": "Completion analytics" }, + { "key": "tutorialEngagement", "title": "Tutorial Engagement", "requiredScope": "Tutorial.Author" }, + { "key": "tutorialCompletions", "title": "Tutorial Completions", "requiredScope": "Tutorial.Author" }, + { "key": "tutorialSurvey", "title": "Tutorial Survey", "href": "/analytics-ui/#/reports/survey", "target": "_self", "requiredScope": "Tutorial.Author" }, { "key": "statistics", "title": "Statistics" }, { "key": "metrics", "title": "Metrics" }, { "key": "dataExport", "title": "Data Export" } diff --git a/app/admin/channel-collections/package.json b/app/admin/channel-collections/package.json new file mode 100644 index 000000000..fa4f066f7 --- /dev/null +++ b/app/admin/channel-collections/package.json @@ -0,0 +1,15 @@ +{ + "name": "channel-collections-admin", + "version": "0.0.1", + "private": true, + "description": "Channel Collections - Admin Fiori Elements", + "sapux": true, + "scripts": { + "start": "fiori run --open index.html", + "build": "ui5 build --clean-dest" + }, + "devDependencies": { + "@sap/ux-specification": "latest", + "@ui5/cli": "^4.0.0" + } +} diff --git a/app/admin/channel-collections/ui5.yaml b/app/admin/channel-collections/ui5.yaml new file mode 100644 index 000000000..437804d82 --- /dev/null +++ b/app/admin/channel-collections/ui5.yaml @@ -0,0 +1,12 @@ +specVersion: "4.0" +metadata: + name: sap.tutorials.admin.channelCollections +type: application +framework: + name: SAPUI5 + version: "1.136.0" + libraries: + - name: sap.m + - name: sap.ui.core + - name: sap.ushell + - name: sap.fe.templates diff --git a/app/admin/channel-collections/webapp/Component.js b/app/admin/channel-collections/webapp/Component.js new file mode 100644 index 000000000..2565f7d12 --- /dev/null +++ b/app/admin/channel-collections/webapp/Component.js @@ -0,0 +1,4 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("sap.tutorials.admin.channelCollections.Component", { metadata: { manifest: "json" } }); +}); diff --git a/app/admin/channel-collections/webapp/i18n/i18n.properties b/app/admin/channel-collections/webapp/i18n/i18n.properties new file mode 100644 index 000000000..6dd7090b9 --- /dev/null +++ b/app/admin/channel-collections/webapp/i18n/i18n.properties @@ -0,0 +1,2 @@ +appTitle=Channel Collections +appDescription=Manage editorial channel collections diff --git a/app/admin/channel-collections/webapp/manifest.json b/app/admin/channel-collections/webapp/manifest.json new file mode 100644 index 000000000..5b0f48c6e --- /dev/null +++ b/app/admin/channel-collections/webapp/manifest.json @@ -0,0 +1,82 @@ +{ + "_version": "1.65.0", + "sap.app": { + "id": "sap.tutorials.admin.channelCollections", + "type": "application", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "applicationVersion": { "version": "0.0.1" }, + "i18n": "i18n/i18n.properties", + "dataSources": { + "mainService": { + "uri": "/admin/", + "type": "OData", + "settings": { "odataVersion": "4.0" } + } + }, + "crossNavigation": { + "inbounds": { + "ChannelCollections-manage": { + "semanticObject": "ChannelCollections", + "action": "manage", + "title": "{{appTitle}}", + "signature": { "parameters": {}, "additionalParameters": "allowed" } + } + } + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.136.0", + "libs": { "sap.fe.templates": {} } + }, + "models": { + "": { + "dataSource": "mainService", + "preload": true, + "settings": { + "synchronizationMode": "None", + "operationMode": "Server", + "autoExpandSelect": true, + "earlyRequests": true + } + }, + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { "bundleName": "sap.tutorials.admin.channelCollections.i18n.i18n" } + } + }, + "routing": { + "routes": [ + { "name": "ChannelCollectionsList", "pattern": ":?query:", "target": "ChannelCollectionsList" }, + { "name": "ChannelCollectionsObject", "pattern": "ChannelCollections({key}):?query:", "target": "ChannelCollectionsObject" } + ], + "targets": { + "ChannelCollectionsList": { + "type": "Component", + "id": "ChannelCollectionsList", + "name": "sap.fe.templates.ListReport", + "options": { + "settings": { + "contextPath": "/ChannelCollections", + "initialLoad": "Enabled", + "navigation": { + "ChannelCollections": { "detail": { "route": "ChannelCollectionsObject" } } + } + } + } + }, + "ChannelCollectionsObject": { + "type": "Component", + "id": "ChannelCollectionsObject", + "name": "sap.fe.templates.ObjectPage", + "options": { + "settings": { + "contextPath": "/ChannelCollections" + } + } + } + } + } + } +} diff --git a/app/admin/channel-submissions/package.json b/app/admin/channel-submissions/package.json new file mode 100644 index 000000000..99f6526f2 --- /dev/null +++ b/app/admin/channel-submissions/package.json @@ -0,0 +1,15 @@ +{ + "name": "channel-submissions-admin", + "version": "0.0.1", + "private": true, + "description": "Channel Submissions - Admin Fiori Elements", + "sapux": true, + "scripts": { + "start": "fiori run --open index.html", + "build": "ui5 build --clean-dest" + }, + "devDependencies": { + "@sap/ux-specification": "latest", + "@ui5/cli": "^4.0.0" + } +} diff --git a/app/admin/channel-submissions/ui5.yaml b/app/admin/channel-submissions/ui5.yaml new file mode 100644 index 000000000..3b3302da3 --- /dev/null +++ b/app/admin/channel-submissions/ui5.yaml @@ -0,0 +1,12 @@ +specVersion: "4.0" +metadata: + name: sap.tutorials.admin.channelSubmissions +type: application +framework: + name: SAPUI5 + version: "1.136.0" + libraries: + - name: sap.m + - name: sap.ui.core + - name: sap.ushell + - name: sap.fe.templates diff --git a/app/admin/channel-submissions/webapp/Component.js b/app/admin/channel-submissions/webapp/Component.js new file mode 100644 index 000000000..2dfdac3b5 --- /dev/null +++ b/app/admin/channel-submissions/webapp/Component.js @@ -0,0 +1,4 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("sap.tutorials.admin.channelSubmissions.Component", { metadata: { manifest: "json" } }); +}); diff --git a/app/admin/channel-submissions/webapp/i18n/i18n.properties b/app/admin/channel-submissions/webapp/i18n/i18n.properties new file mode 100644 index 000000000..4373a8a8d --- /dev/null +++ b/app/admin/channel-submissions/webapp/i18n/i18n.properties @@ -0,0 +1,4 @@ +appTitle=Channel Submissions +appDescription=Moderation queue for community channel submissions +approve=Approve +reject=Reject diff --git a/app/admin/channel-submissions/webapp/manifest.json b/app/admin/channel-submissions/webapp/manifest.json new file mode 100644 index 000000000..f47fbe152 --- /dev/null +++ b/app/admin/channel-submissions/webapp/manifest.json @@ -0,0 +1,79 @@ +{ + "_version": "1.65.0", + "sap.app": { + "id": "sap.tutorials.admin.channelSubmissions", + "type": "application", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "applicationVersion": { "version": "0.0.1" }, + "i18n": "i18n/i18n.properties", + "dataSources": { + "mainService": { + "uri": "/admin/", + "type": "OData", + "settings": { "odataVersion": "4.0" } + } + }, + "crossNavigation": { + "inbounds": { + "ChannelSubmissions-manage": { + "semanticObject": "ChannelSubmissions", + "action": "manage", + "title": "{{appTitle}}", + "signature": { "parameters": {}, "additionalParameters": "allowed" } + } + } + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.136.0", + "libs": { "sap.fe.templates": {} } + }, + "models": { + "": { + "dataSource": "mainService", + "preload": true, + "settings": { + "synchronizationMode": "None", + "operationMode": "Server", + "autoExpandSelect": true, + "earlyRequests": true + } + }, + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { "bundleName": "sap.tutorials.admin.channelSubmissions.i18n.i18n" } + } + }, + "routing": { + "routes": [ + { "name": "ChannelSubmissionsList", "pattern": ":?query:", "target": "ChannelSubmissionsList" }, + { "name": "ChannelSubmissionsObject", "pattern": "ChannelSubmissions({key}):?query:", "target": "ChannelSubmissionsObject" } + ], + "targets": { + "ChannelSubmissionsList": { + "type": "Component", + "id": "ChannelSubmissionsList", + "name": "sap.fe.templates.ListReport", + "options": { + "settings": { + "contextPath": "/ChannelSubmissions", + "initialLoad": "Enabled" + } + } + }, + "ChannelSubmissionsObject": { + "type": "Component", + "id": "ChannelSubmissionsObject", + "name": "sap.fe.templates.ObjectPage", + "options": { + "settings": { + "contextPath": "/ChannelSubmissions" + } + } + } + } + } + } +} diff --git a/app/admin/channel-topic-map/package.json b/app/admin/channel-topic-map/package.json new file mode 100644 index 000000000..ea3ffc939 --- /dev/null +++ b/app/admin/channel-topic-map/package.json @@ -0,0 +1,15 @@ +{ + "name": "channel-topic-map-admin", + "version": "0.0.1", + "private": true, + "description": "Channel Topic Map - Admin Fiori Elements", + "sapux": true, + "scripts": { + "start": "fiori run --open index.html", + "build": "ui5 build --clean-dest" + }, + "devDependencies": { + "@sap/ux-specification": "latest", + "@ui5/cli": "^4.0.0" + } +} diff --git a/app/admin/channel-topic-map/ui5.yaml b/app/admin/channel-topic-map/ui5.yaml new file mode 100644 index 000000000..1bf4bd8ed --- /dev/null +++ b/app/admin/channel-topic-map/ui5.yaml @@ -0,0 +1,12 @@ +specVersion: "4.0" +metadata: + name: sap.tutorials.admin.channelTopicMap +type: application +framework: + name: SAPUI5 + version: "1.136.0" + libraries: + - name: sap.m + - name: sap.ui.core + - name: sap.ushell + - name: sap.fe.templates diff --git a/app/admin/channel-topic-map/webapp/Component.js b/app/admin/channel-topic-map/webapp/Component.js new file mode 100644 index 000000000..fe8102672 --- /dev/null +++ b/app/admin/channel-topic-map/webapp/Component.js @@ -0,0 +1,4 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("sap.tutorials.admin.channelTopicMap.Component", { metadata: { manifest: "json" } }); +}); diff --git a/app/admin/channel-topic-map/webapp/i18n/i18n.properties b/app/admin/channel-topic-map/webapp/i18n/i18n.properties new file mode 100644 index 000000000..579287cc8 --- /dev/null +++ b/app/admin/channel-topic-map/webapp/i18n/i18n.properties @@ -0,0 +1,2 @@ +appTitle=Channel Topic Map +appDescription=Review and promote the channel-to-topic crosswalk diff --git a/app/admin/channel-topic-map/webapp/manifest.json b/app/admin/channel-topic-map/webapp/manifest.json new file mode 100644 index 000000000..a69b67040 --- /dev/null +++ b/app/admin/channel-topic-map/webapp/manifest.json @@ -0,0 +1,82 @@ +{ + "_version": "1.65.0", + "sap.app": { + "id": "sap.tutorials.admin.channelTopicMap", + "type": "application", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "applicationVersion": { "version": "0.0.1" }, + "i18n": "i18n/i18n.properties", + "dataSources": { + "mainService": { + "uri": "/admin/", + "type": "OData", + "settings": { "odataVersion": "4.0" } + } + }, + "crossNavigation": { + "inbounds": { + "ChannelTopicMap-manage": { + "semanticObject": "ChannelTopicMap", + "action": "manage", + "title": "{{appTitle}}", + "signature": { "parameters": {}, "additionalParameters": "allowed" } + } + } + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.136.0", + "libs": { "sap.fe.templates": {} } + }, + "models": { + "": { + "dataSource": "mainService", + "preload": true, + "settings": { + "synchronizationMode": "None", + "operationMode": "Server", + "autoExpandSelect": true, + "earlyRequests": true + } + }, + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { "bundleName": "sap.tutorials.admin.channelTopicMap.i18n.i18n" } + } + }, + "routing": { + "routes": [ + { "name": "ChannelTopicMapList", "pattern": ":?query:", "target": "ChannelTopicMapList" }, + { "name": "ChannelTopicMapObject", "pattern": "ChannelTopicMap({key}):?query:", "target": "ChannelTopicMapObject" } + ], + "targets": { + "ChannelTopicMapList": { + "type": "Component", + "id": "ChannelTopicMapList", + "name": "sap.fe.templates.ListReport", + "options": { + "settings": { + "contextPath": "/ChannelTopicMap", + "initialLoad": "Enabled", + "navigation": { + "ChannelTopicMap": { "detail": { "route": "ChannelTopicMapObject" } } + } + } + } + }, + "ChannelTopicMapObject": { + "type": "Component", + "id": "ChannelTopicMapObject", + "name": "sap.fe.templates.ObjectPage", + "options": { + "settings": { + "contextPath": "/ChannelTopicMap" + } + } + } + } + } + } +} diff --git a/app/admin/channels/package.json b/app/admin/channels/package.json new file mode 100644 index 000000000..1daa19704 --- /dev/null +++ b/app/admin/channels/package.json @@ -0,0 +1,15 @@ +{ + "name": "sap.tutorials.admin.channels", + "version": "0.0.1", + "private": true, + "description": "Channels - Admin Fiori Elements", + "sapux": true, + "scripts": { + "start": "fiori run --open index.html", + "build": "ui5 build --clean-dest" + }, + "devDependencies": { + "@sap/ux-specification": "latest", + "@ui5/cli": "^4.0.0" + } +} diff --git a/app/admin/channels/ui5.yaml b/app/admin/channels/ui5.yaml new file mode 100644 index 000000000..c7e9834b1 --- /dev/null +++ b/app/admin/channels/ui5.yaml @@ -0,0 +1,12 @@ +specVersion: "4.0" +metadata: + name: sap.tutorials.admin.channels +type: application +framework: + name: SAPUI5 + version: "1.136.0" + libraries: + - name: sap.m + - name: sap.ui.core + - name: sap.ushell + - name: sap.fe.templates diff --git a/app/admin/channels/webapp/Component.js b/app/admin/channels/webapp/Component.js new file mode 100644 index 000000000..26ef6f26b --- /dev/null +++ b/app/admin/channels/webapp/Component.js @@ -0,0 +1,4 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("sap.tutorials.admin.channels.Component", { metadata: { manifest: "json" } }); +}); diff --git a/app/admin/channels/webapp/i18n/i18n.properties b/app/admin/channels/webapp/i18n/i18n.properties new file mode 100644 index 000000000..62e62595a --- /dev/null +++ b/app/admin/channels/webapp/i18n/i18n.properties @@ -0,0 +1,2 @@ +appTitle=Channels +appDescription=Curate external SAP developer channels diff --git a/app/admin/channels/webapp/manifest.json b/app/admin/channels/webapp/manifest.json new file mode 100644 index 000000000..a1147868a --- /dev/null +++ b/app/admin/channels/webapp/manifest.json @@ -0,0 +1,82 @@ +{ + "_version": "1.65.0", + "sap.app": { + "id": "sap.tutorials.admin.channels", + "type": "application", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "applicationVersion": { "version": "0.0.1" }, + "i18n": "i18n/i18n.properties", + "dataSources": { + "mainService": { + "uri": "/admin/", + "type": "OData", + "settings": { "odataVersion": "4.0" } + } + }, + "crossNavigation": { + "inbounds": { + "Channels-manage": { + "semanticObject": "Channels", + "action": "manage", + "title": "{{appTitle}}", + "signature": { "parameters": {}, "additionalParameters": "allowed" } + } + } + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.136.0", + "libs": { "sap.fe.templates": {} } + }, + "models": { + "": { + "dataSource": "mainService", + "preload": true, + "settings": { + "synchronizationMode": "None", + "operationMode": "Server", + "autoExpandSelect": true, + "earlyRequests": true + } + }, + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { "bundleName": "sap.tutorials.admin.channels.i18n.i18n" } + } + }, + "routing": { + "routes": [ + { "name": "ChannelsList", "pattern": ":?query:", "target": "ChannelsList" }, + { "name": "ChannelsObject", "pattern": "Channels({key}):?query:", "target": "ChannelsObject" } + ], + "targets": { + "ChannelsList": { + "type": "Component", + "id": "ChannelsList", + "name": "sap.fe.templates.ListReport", + "options": { + "settings": { + "contextPath": "/Channels", + "initialLoad": "Enabled", + "navigation": { + "Channels": { "detail": { "route": "ChannelsObject" } } + } + } + } + }, + "ChannelsObject": { + "type": "Component", + "id": "ChannelsObject", + "name": "sap.fe.templates.ObjectPage", + "options": { + "settings": { + "contextPath": "/Channels" + } + } + } + } + } + } +} diff --git a/app/admin/events/webapp/ext/LogoController.js b/app/admin/events/webapp/ext/LogoController.js new file mode 100644 index 000000000..3195ada00 --- /dev/null +++ b/app/admin/events/webapp/ext/LogoController.js @@ -0,0 +1,137 @@ +// Plain UI5 module for the Events Object Page header actions (#2133). +// +// Mirrors app/admin/devtoberfest/webapp/ext/BannerController.js — see that file +// for the full rationale on why this is a bare `sap.ui.define` module (NOT a +// ControllerExtension): FE V4 resolves manifest `press` references as plain +// modules (loader path `.js`), and press handlers get the binding +// context(s) directly. +// +// The logo is written via the `uploadEventLogo` BOUND ACTION on +// AdminService.Events, which takes base64 (`imageBase64`, `mimeType`) and runs +// the sharp → WebP → BLOB pipeline server-side. We do NOT use a Fiori UploadSet: +// the logo is a 1:1 composition whose key IS the parent association, and FE's +// UploadSet "Create" POSTs a new composition row, which OData rejects. +sap.ui.define([ + "sap/m/MessageToast", + "sap/m/MessageBox" +], function (MessageToast, MessageBox) { + "use strict"; + + // Resolve the binding context regardless of how FE V4 invoked the handler. + function resolveCtx(arg) { + if (!arg) return null; + if (Array.isArray(arg)) return arg[0] || null; + if (typeof arg.getModel === "function") return arg; + if (typeof arg.getSource === "function") { + const src = arg.getSource(); + if (src && typeof src.getBindingContext === "function") { + return src.getBindingContext(); + } + } + return null; + } + + // Read a File into a base64 string (no data: prefix — the server strips it + // defensively anyway, but we send the bare payload). + function fileToBase64(file) { + return new Promise(function (resolve, reject) { + const reader = new FileReader(); + reader.onload = function () { + const comma = String(reader.result).indexOf(","); + resolve(comma >= 0 ? String(reader.result).slice(comma + 1) : String(reader.result)); + }; + reader.onerror = function () { reject(reader.error || new Error("read failed")); }; + reader.readAsDataURL(file); + }); + } + + return { + + /** + * Header action: prompt for an image file, base64-encode it client-side, + * and invoke the AdminService.uploadEventLogo bound action on the current + * event. The server runs the sharp pipeline (resize → WebP), upserts the + * EventLogo row, and flips hasLogo + logoUpdatedAt. + */ + onUploadEventLogoPress: function (arg) { + const ctx = resolveCtx(arg); + if (!ctx) { + MessageToast.show("Open an event first"); + return; + } + const ev = ctx.getObject ? ctx.getObject() : null; + if (ev && ev.IsActiveEntity === false) { + MessageBox.warning( + "Save or cancel your current edits before uploading a logo. " + + "The logo applies to the saved record, not to the draft." + ); + return; + } + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/jpeg,image/png,image/webp"; + input.style.display = "none"; + document.body.appendChild(input); + input.addEventListener("change", async function () { + try { + const file = input.files && input.files[0]; + if (!file) return; + if (file.size > 8 * 1024 * 1024) { + MessageBox.error("Logo too large (max 8 MB)."); + return; + } + MessageToast.show("Uploading logo…"); + const imageBase64 = await fileToBase64(file); + const model = ctx.getModel(); + const op = model.bindContext("AdminService.uploadEventLogo(...)", ctx); + op.setParameter("imageBase64", imageBase64); + op.setParameter("mimeType", file.type || "image/png"); + await op.execute(); + MessageToast.show("Logo uploaded."); + if (ctx.refresh) ctx.refresh(); + } catch (err) { + MessageBox.error("Logo upload failed: " + (err && err.message ? err.message : err)); + } finally { + input.remove(); + } + }); + input.click(); + }, + + /** + * Header action: confirm + call AdminService.clearEventLogo. Drops the + * EventLogo row and flips hasLogo=false. + */ + onClearEventLogoPress: function (arg) { + const ctx = resolveCtx(arg); + if (!ctx) { + MessageToast.show("Open an event first"); + return; + } + const ev = ctx.getObject ? ctx.getObject() : null; + if (ev && ev.IsActiveEntity === false) { + MessageBox.warning("Save or cancel your current edits before clearing the logo."); + return; + } + MessageBox.confirm( + "Remove this event's logo? The image will be deleted from the server.", + { + title: "Clear logo", + onClose: async function (action) { + if (action !== MessageBox.Action.OK) return; + try { + const model = ctx.getModel(); + const op = model.bindContext("AdminService.clearEventLogo(...)", ctx); + await op.execute(); + MessageToast.show("Logo cleared."); + if (ctx.refresh) ctx.refresh(); + } catch (err) { + MessageBox.error("Clear failed: " + (err && err.message ? err.message : err)); + } + } + } + ); + } + + }; +}); diff --git a/app/admin/events/webapp/manifest.json b/app/admin/events/webapp/manifest.json index 6a3725bce..0b8eb90fd 100644 --- a/app/admin/events/webapp/manifest.json +++ b/app/admin/events/webapp/manifest.json @@ -77,7 +77,25 @@ "options": { "settings": { "contextPath": "/Events", - "editableHeaderContent": false + "editableHeaderContent": false, + "content": { + "header": { + "actions": { + "uploadEventLogo": { + "press": "sap.tutorials.admin.events.ext.LogoController.onUploadEventLogoPress", + "visible": true, + "enabled": "{= ${IsActiveEntity} === true }", + "text": "Upload Logo" + }, + "clearEventLogo": { + "press": "sap.tutorials.admin.events.ext.LogoController.onClearEventLogoPress", + "visible": "{= !!${hasLogo} }", + "enabled": "{= ${IsActiveEntity} === true }", + "text": "Clear Logo" + } + } + } + } } } } diff --git a/app/admin/puzzles/webapp/controller/Builder.controller.js b/app/admin/puzzles/webapp/controller/Builder.controller.js index 83ec55d9d..55b792d04 100644 --- a/app/admin/puzzles/webapp/controller/Builder.controller.js +++ b/app/admin/puzzles/webapp/controller/Builder.controller.js @@ -755,6 +755,7 @@ sap.ui.define([ id: slot.id, dir: slot.dir, label: label, + num: num, len: slot.len, clue: clues[slot.id] || "", hint: hints[slot.id] || "", @@ -767,6 +768,17 @@ sap.ui.define([ } }); + // findSlots emits Down slots in column-major (grid-position) order, but + // clue numbers are assigned row-major — so sort both lists by clue number + // to list them in the numeric order authors expect (#2140). + var byNumber = function (a, b) { + var na = a.num == null ? Number.MAX_SAFE_INTEGER : a.num; + var nb = b.num == null ? Number.MAX_SAFE_INTEGER : b.num; + return na - nb; + }; + across.sort(byNumber); + down.sort(byNumber); + b.setProperty("/slotsAcross", across); b.setProperty("/slotsDown", down); }, diff --git a/app/admin/tutorial-completions/webapp/Component.js b/app/admin/tutorial-completions/webapp/Component.js new file mode 100644 index 000000000..2b57318b6 --- /dev/null +++ b/app/admin/tutorial-completions/webapp/Component.js @@ -0,0 +1,6 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("sap.tutorials.admin.tutorialCompletions.Component", { + metadata: { manifest: "json" } + }); +}); diff --git a/app/admin/tutorial-completions/webapp/manifest.json b/app/admin/tutorial-completions/webapp/manifest.json new file mode 100644 index 000000000..e09b39ce7 --- /dev/null +++ b/app/admin/tutorial-completions/webapp/manifest.json @@ -0,0 +1,64 @@ +{ + "_version": "1.65.0", + "sap.app": { + "id": "sap.tutorials.admin.tutorialCompletions", + "type": "application", + "title": "Tutorial Completions", + "description": "Tutorial completion events over time by mission/group", + "applicationVersion": { "version": "0.0.1" }, + "dataSources": { + "mainService": { + "uri": "/author/", + "type": "OData", + "settings": { "odataVersion": "4.0" } + } + }, + "crossNavigation": { + "inbounds": { + "TutorialCompletions-display": { + "semanticObject": "TutorialCompletions", + "action": "display", + "title": "Tutorial Completions", + "signature": { "parameters": {}, "additionalParameters": "allowed" } + } + } + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.136.0", + "libs": { "sap.fe.templates": {} } + }, + "models": { + "": { + "dataSource": "mainService", + "preload": true, + "settings": { + "synchronizationMode": "None", + "operationMode": "Server", + "autoExpandSelect": true, + "earlyRequests": true + } + } + }, + "routing": { + "routes": [ + { "pattern": ":?query:", "name": "TutorialCompletionsList", "target": "TutorialCompletionsList" } + ], + "targets": { + "TutorialCompletionsList": { + "type": "Component", + "id": "TutorialCompletionsList", + "name": "sap.fe.templates.ListReport", + "options": { + "settings": { + "contextPath": "/AuthorTutorialCompletions", + "variantManagement": "Page", + "initialLoad": "Enabled" + } + } + } + } + } + } +} diff --git a/app/admin/tutorial-engagement/webapp/Component.js b/app/admin/tutorial-engagement/webapp/Component.js new file mode 100644 index 000000000..cb8b68e25 --- /dev/null +++ b/app/admin/tutorial-engagement/webapp/Component.js @@ -0,0 +1,6 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("sap.tutorials.admin.tutorialEngagement.Component", { + metadata: { manifest: "json" } + }); +}); diff --git a/app/admin/tutorial-engagement/webapp/manifest.json b/app/admin/tutorial-engagement/webapp/manifest.json new file mode 100644 index 000000000..48ffd08bb --- /dev/null +++ b/app/admin/tutorial-engagement/webapp/manifest.json @@ -0,0 +1,71 @@ +{ + "_version": "1.65.0", + "sap.app": { + "id": "sap.tutorials.admin.tutorialEngagement", + "type": "application", + "title": "Tutorial Engagement", + "description": "Starts vs completions per tutorial within a mission/group", + "applicationVersion": { "version": "0.0.1" }, + "dataSources": { + "mainService": { + "uri": "/author/", + "type": "OData", + "settings": { "odataVersion": "4.0" } + } + }, + "crossNavigation": { + "inbounds": { + "TutorialEngagement-analyze": { + "semanticObject": "TutorialEngagement", + "action": "analyze", + "title": "Tutorial Engagement", + "signature": { "parameters": {}, "additionalParameters": "allowed" } + } + } + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.136.0", + "libs": { "sap.fe.templates": {}, "sap.m": {}, "sap.ui.core": {} } + }, + "models": { + "": { + "dataSource": "mainService", + "preload": true, + "settings": { + "synchronizationMode": "None", + "operationMode": "Server", + "autoExpandSelect": true, + "earlyRequests": true + } + } + }, + "routing": { + "routes": [ + { "pattern": ":?query:", "name": "TutorialEngagementALP", "target": "TutorialEngagementALP" } + ], + "targets": { + "TutorialEngagementALP": { + "type": "Component", + "id": "TutorialEngagementALP", + "name": "sap.fe.templates.AnalyticalListPage", + "options": { + "settings": { + "contextPath": "/AuthorTutorialEngagement", + "variantManagement": "Page", + "initialLoad": "Enabled", + "hideVisualFilter": true, + "controlConfiguration": { + "@com.sap.vocabularies.UI.v1.Chart": { "chartInitiallyExpanded": true }, + "@com.sap.vocabularies.UI.v1.LineItem": { + "tableSettings": { "type": "AnalyticalTable" } + } + } + } + } + } + } + } + } +} diff --git a/app/analytics-explorer/src/App.vue b/app/analytics-explorer/src/App.vue index 3804f6498..4f6d1fd1e 100644 --- a/app/analytics-explorer/src/App.vue +++ b/app/analytics-explorer/src/App.vue @@ -8,11 +8,16 @@ import '@ui5/webcomponents/dist/Title.js' import '@ui5/webcomponents/dist/Popover.js' import '@ui5/webcomponents/dist/List.js' import '@ui5/webcomponents/dist/ListItemStandard.js' +import { useRouter } from 'vue-router' import { useTheme, type ThemeMode } from './composables/useTheme' import JoulePanel from './components/joule/JoulePanel.vue' import { useQuerySpec } from './composables/useQuerySpec' import { useAuth } from './composables/useAuth' +const router = useRouter() +function goHome() { router.push('/') } +function goSurvey() { router.push('/reports/survey') } + const { themeMode, cycleThemeMode, setThemeMode } = useTheme() // Shared singleton — Analytics.vue + the API modules read the same role state. @@ -102,6 +107,8 @@ function onNotificationsClick() { /* TODO: notifications popover */ }