You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/09/04 07:50:39 UTC
(causeway) 10/11: fix(webcomponents): consolidate utility menu and action results
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git
commit 2b64f785f3239262c07905e3156bf70de5ceca8b
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Fri Sep 4 08:38:31 2026 +0100
fix(webcomponents): consolidate utility menu and action results
---
.../bootstrap/MenuBarsServiceBootstrap.java | 42 +-
.../menubars/bootstrap/MenuBarsServiceBSTest.java | 17 +
.../tasks.md | 36 +-
viewers/webcomponents/README.adoc | 3 +-
viewers/webcomponents/foundation/USAGE.adoc | 9 +-
.../foundation/src/component-styles.css | 17 +
.../foundation/src/component-styles.mjs | 17 +
.../foundation/src/menubar-element.mjs | 32 +-
.../foundation/src/menubar-projection.mjs | 39 +-
.../foundation/src/menubar-widget.mjs | 10 +-
.../foundation/src/menubars-element.mjs | 24 +
.../foundation/test/component-styles.test.mjs | 5 +
.../foundation/test/menubar-component.test.mjs | 11 +-
.../foundation/test/menubar-projection.test.mjs | 37 +-
.../foundation/test/menubar-widget.test.mjs | 7 +-
.../webcomponents/htmx-security-secman/README.adoc | 2 +-
.../webcomponents/htmx/HtmxPageRenderer.java | 2 +
.../causeway-htmx/authentication-policy.mjs | 22 +-
.../resources/causeway-htmx/causeway-htmx.mjs | 6 +-
.../htmx/HtmxViewerControllerTest.java | 1 +
.../htmx/test/authentication-policy.test.mjs | 15 +-
.../sample-htmx-petclinic/README.adoc | 6 +-
.../petclinic/PetClinicHtmxPlaywrightTest.java | 3 +
.../PetClinicHtmxSecuredPlaywrightTest.java | 42 +-
.../src/main/resources/menubars.layout.xml | 5 -
.../webcomponents/sample-vue-petclinic/README.adoc | 8 +-
.../sample-vue-petclinic/frontend/src/main.ts | 10 +
.../resources/static/vue/assets/index-BVs2fZoR.js | 2 -
.../resources/static/vue/assets/index-C3yUz-LS.js | 2 +
...index-BVs2fZoR.js.map => index-C3yUz-LS.js.map} | 2 +-
.../src/main/resources/static/vue/index.html | 2 +-
.../vue/petclinic/PetClinicVuePlaywrightTest.java | 7 +
.../PetClinicVueSecuredPlaywrightAcceptance.java | 72 ++-
.../webcomponents/vue-security-secman/README.adoc | 3 +-
viewers/webcomponents/vue/README.adoc | 9 +-
viewers/webcomponents/vue/dist/causeway-vue.js | 641 +++++++++++----------
viewers/webcomponents/vue/dist/causeway-vue.js.map | 2 +-
viewers/webcomponents/vue/dist/contracts.d.ts | 7 +
viewers/webcomponents/vue/dist/contracts.d.ts.map | 2 +-
viewers/webcomponents/vue/dist/policy.d.ts.map | 2 +-
viewers/webcomponents/vue/src/contracts.ts | 8 +
viewers/webcomponents/vue/src/policy.ts | 61 +-
.../vue/test/policy-integration.test.ts | 98 +++-
43 files changed, 917 insertions(+), 431 deletions(-)
diff --git a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/menubars/bootstrap/MenuBarsServiceBootstrap.java b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/menubars/bootstrap/MenuBarsServiceBootstrap.java
index 7cdcc3b8e44..b6c43b5f607 100644
--- a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/menubars/bootstrap/MenuBarsServiceBootstrap.java
+++ b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/menubars/bootstrap/MenuBarsServiceBootstrap.java
@@ -126,10 +126,22 @@ private BSMenuBars loadOrElse(
.map(attributesAppender::appendAttributes)
.orElse(menuBarsFromAnnotationsOnly);
- var unreferencedActionsMenu = validateAndGetUnreferencedActionMenu(menuBars);
- if (unreferencedActionsMenu == null)
- // just use fallback
+ var unreferencedActionMenus = findUnreferencedActionMenus(menuBars);
+ if (unreferencedActionMenus.isEmpty()) {
+ // An authored layout may deliberately omit actions that it does not reference.
+ return menuBars;
+ }
+ if (unreferencedActionMenus.size() > 1) {
+ menuBars.setMetadataError(
+ "At most one menu may have 'unreferencedActions' flag set; found "
+ + unreferencedActionMenus.size() + " such menus");
+ if(metaModelContext.getSystemEnvironment().isPrototyping()) {
+ messageService.warnUser("Menubars metadata errors; check the error log");
+ }
+ log.error("Menubar layout metadata errors:\n\n{}\n\n", jaxbService.toXml(menuBars));
return menuBarsFromAnnotationsOnly;
+ }
+ var unreferencedActionsMenu = unreferencedActionMenus.get(0);
// add in any missing actions from the fallback
var referencedActionsByObjectTypeAndId = menuBars.getAllServiceActionsByObjectTypeAndId();
@@ -206,30 +218,14 @@ private static void bindActionToSection(
section.getServiceActions().add(serviceAction);
}
- private BSMenu validateAndGetUnreferencedActionMenu(final BSMenuBars menuBars) {
-
- if (menuBars == null)
- return null;
-
- var menusWithUnreferencedActionsFlagSet = new ArrayList<BSMenu>();
+ private static List<BSMenu> findUnreferencedActionMenus(final BSMenuBars menuBars) {
+ var menus = new ArrayList<BSMenu>();
menuBars.visit(BSMenuBars.VisitorAdapter.visitingMenus(menu->{
if(Boolean.TRUE.equals(menu.isUnreferencedActions())) {
- menusWithUnreferencedActionsFlagSet.add(menu);
+ menus.add(menu);
}
}));
-
- var size = menusWithUnreferencedActionsFlagSet.size();
- if (size == 1)
- return menusWithUnreferencedActionsFlagSet.get(0);
-
- menuBars.setMetadataError(
- "Exactly one menu must have 'unreferencedActions' flag set; found " + size + " such menus");
- if(metaModelContext.getSystemEnvironment().isPrototyping()) {
- messageService.warnUser("Menubars metadata errors; check the error log");
- }
- log.error("Menubar layout metadata errors:\n\n{}\n\n", jaxbService.toXml(menuBars));
-
- return null;
+ return menus;
}
private BSMenuBars menuBarsFromAnnotationsOnly() {
diff --git a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/menubars/bootstrap/MenuBarsServiceBSTest.java b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/menubars/bootstrap/MenuBarsServiceBSTest.java
index b4fabf97c30..698883cd306 100644
--- a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/menubars/bootstrap/MenuBarsServiceBSTest.java
+++ b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/menubars/bootstrap/MenuBarsServiceBSTest.java
@@ -19,6 +19,7 @@
package org.apache.causeway.core.runtimeservices.menubars.bootstrap;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -109,6 +110,22 @@ void roundtrip(final CommonMimeType format) {
assertEquals(null, layoutData2.getNamedEscaped()); // deprecated: always escape
}
+ @Test
+ void authoredLayoutWithoutUnreferencedActionMenuIsRetained() {
+ var customNamed = "Deliberately selected action";
+ var xml = sampleMenuBarsXmlWithCustomName(customNamed)
+ .replace(" unreferencedActions=\"true\"", "");
+ assertFalse(xml.contains("unreferencedActions=\"true\""));
+
+ getSpecificationLoader().disposeMetaModel();
+ super.menubarsLayoutXmlResourceRef.set(new ByteArrayResource(xml.getBytes(StandardCharsets.UTF_8)));
+ getSpecificationLoader().createMetaModel();
+
+ var menuBars = menuBarsService.menuBars();
+ assertEquals(1L, menuBars.stream().count());
+ assertEquals(customNamed, menuBars.stream().findFirst().orElseThrow().getNamed());
+ }
+
@Test
void customNamed() {
diff --git a/openspec/changes/fix-account-menu-and-vue-action-results/tasks.md b/openspec/changes/fix-account-menu-and-vue-action-results/tasks.md
index e4ee077e737..ac3ed558853 100644
--- a/openspec/changes/fix-account-menu-and-vue-action-results/tasks.md
+++ b/openspec/changes/fix-account-menu-and-vue-action-results/tasks.md
@@ -1,32 +1,32 @@
## 1. Consolidated Petclinic utility menu
-- [ ] 1.1 Remove the shared Petclinic `System` unreferenced-action catch-all while retaining one authoritative tertiary utility section.
-- [ ] 1.2 Add bounded host menu-title projection so the utility section displays the authoritative current username, renders it as text, and falls back to Account only when no trustworthy identity is available.
-- [ ] 1.3 Wire ordinary and secured HTMX and Vue hosts to their established current-user context without adding authentication discovery to generic component packages.
-- [ ] 1.4 Add an appearance token for exact `causeway.security.LogoutMenu#logout` and render **Sign out** with a distinct accessible outlined or equivalently bounded treatment in native and Vaadin-backed menus.
-- [ ] 1.5 Verify adjacent actions retain ordinary presentation and that username/title/appearance mapping changes neither identity, authorization, ordering, invocation, nor CSRF-safe Logout ownership.
-- [ ] 1.6 Verify ordinary and secured HTMX and Vue expose one intended authorized utility menu without a duplicate or empty System or Account menu.
+- [x] 1.1 Remove the shared Petclinic `System` unreferenced-action catch-all while retaining one authoritative tertiary utility section.
+- [x] 1.2 Add bounded host menu-title projection so the utility section displays the authoritative current username, renders it as text, and falls back to Account only when no trustworthy identity is available.
+- [x] 1.3 Wire ordinary and secured HTMX and Vue hosts to their established current-user context without adding authentication discovery to generic component packages.
+- [x] 1.4 Add an appearance token for exact `causeway.security.LogoutMenu#logout` and render **Sign out** with a distinct accessible outlined or equivalently bounded treatment in native and Vaadin-backed menus.
+- [x] 1.5 Verify adjacent actions retain ordinary presentation and that username/title/appearance mapping changes neither identity, authorization, ordering, invocation, nor CSRF-safe Logout ownership.
+- [x] 1.6 Verify ordinary and secured HTMX and Vue expose one intended authorized utility menu without a duplicate or empty System or Account menu.
## 2. Vue object-result navigation
-- [ ] 2.1 Capture and test the normalized `UserMenu#me` semantic result to identify why its advertised view-model identity is not reaching canonical Vue navigation.
-- [ ] 2.2 Correct the responsible shared query/normalization or Vue policy boundary without adding label-, action-, or `UserMemento`-specific identity inference.
-- [ ] 2.3 Add unit coverage for entity and view-model object results with valid identity and for object-shaped results that genuinely lack identity.
+- [x] 2.1 Capture and test the normalized `UserMenu#me` semantic result to identify why its advertised view-model identity is not reaching canonical Vue navigation.
+- [x] 2.2 Correct the responsible shared query/normalization or Vue policy boundary without adding label-, action-, or `UserMemento`-specific identity inference.
+- [x] 2.3 Add unit coverage for entity and view-model object results with valid identity and for object-shaped results that genuinely lack identity.
## 3. Vue result ownership and lifecycle
-- [ ] 3.1 Refactor default Vue result handoff so local-resource, object, scalar, collection, void, and unsupported outcomes dismiss transient source result presentation after acceptance.
-- [ ] 3.2 Clear only obsolete viewer-owned page or shell result presentation when a later unclaimed outcome navigates or replaces it.
-- [ ] 3.3 Preserve application-claimed result ownership and route-generation safeguards against stale asynchronous events.
-- [ ] 3.4 Add Vue browser coverage that opens the username-labelled utility menu, activates **Me**, verifies canonical generic object presentation without header result chrome, then activates **Configuration** and verifies no Me result remains.
+- [x] 3.1 Refactor default Vue result handoff so local-resource, object, scalar, collection, void, and unsupported outcomes dismiss transient source result presentation after acceptance.
+- [x] 3.2 Clear only obsolete viewer-owned page or shell result presentation when a later unclaimed outcome navigates or replaces it.
+- [x] 3.3 Preserve application-claimed result ownership and route-generation safeguards against stale asynchronous events.
+- [x] 3.4 Add Vue browser coverage that opens the username-labelled utility menu, activates **Me**, verifies canonical generic object presentation without header result chrome, then activates **Configuration** and verifies no Me result remains.
## 4. Documentation and generated outputs
-- [ ] 4.1 Update HTMX, Vue, security-integration, and Petclinic documentation for the single username-labelled utility menu, distinct **Sign out** treatment, and Vue result lifecycle.
-- [ ] 4.2 Regenerate and verify Vue package declarations, bundles, source maps, and committed Petclinic production assets when affected.
+- [x] 4.1 Update HTMX, Vue, security-integration, and Petclinic documentation for the single username-labelled utility menu, distinct **Sign out** treatment, and Vue result lifecycle.
+- [x] 4.2 Regenerate and verify Vue package declarations, bundles, source maps, and committed Petclinic production assets when affected.
## 5. Validation
-- [ ] 5.1 Run focused foundation and Vue policy tests for menu-title and action-appearance projection, exact Logout identity, object identity, source dismissal, outlet replacement, and claimed-policy behavior.
-- [ ] 5.2 Run ordinary and secured HTMX and Vue integration and headless browser suites, including username confidentiality and native/Vaadin **Sign out** presentation.
-- [ ] 5.3 Run packaging, license, deterministic-asset, formatting, inspection, and strict OpenSpec checks for all affected modules.
+- [x] 5.1 Run focused foundation and Vue policy tests for menu-title and action-appearance projection, exact Logout identity, object identity, source dismissal, outlet replacement, and claimed-policy behavior.
+- [x] 5.2 Run ordinary and secured HTMX and Vue integration and headless browser suites, including username confidentiality and native/Vaadin **Sign out** presentation.
+- [x] 5.3 Run packaging, license, deterministic-asset, formatting, inspection, and strict OpenSpec checks for all affected modules.
diff --git a/viewers/webcomponents/README.adoc b/viewers/webcomponents/README.adoc
index 9b0bafde970..97ffe428626 100644
--- a/viewers/webcomponents/README.adoc
+++ b/viewers/webcomponents/README.adoc
@@ -125,7 +125,8 @@ Start the corresponding local SecMan and Spring Security variants with:
./viewers/webcomponents/sample-vue-petclinic/run-secured.sh
----
-Both secured applications use username `sven` and password `pass`, require current CSRF evidence for GraphQL and logout POSTs, restore safe viewer deep links after login, and present host-owned **Sign out** in the preserved tertiary Account menu without separate visible authentication chrome.
+Both secured applications use username `sven` and password `pass`, require current CSRF evidence for GraphQL and logout POSTs, restore safe viewer deep links after login, and consolidate utility actions into one tertiary menu labelled `sven` without separate visible authentication chrome.
+Exact host-owned **Sign out** receives a distinct outlined treatment in native and Vaadin-backed menus while retaining its CSRF-safe POST behavior.
Run only one application at a time because all four use port 8080 by default.
Open `http://localhost:8080/htmx` for HTMX or `http://localhost:8080/vue/` for Vue.
The HTMX application also exposes `/wicket/`; sign in as `sven` with password `pass` for comparison.
diff --git a/viewers/webcomponents/foundation/USAGE.adoc b/viewers/webcomponents/foundation/USAGE.adoc
index 865c67580ab..1e6eca7ed92 100644
--- a/viewers/webcomponents/foundation/USAGE.adoc
+++ b/viewers/webcomponents/foundation/USAGE.adoc
@@ -248,8 +248,11 @@ Service interaction events add `target: {kind: "service", logicalTypeName}`, `se
Routing, result rendering, browser history, home behavior, authentication, and navigation remain host policy.
Every action publishes one cancelable `causeway-action-request` before validation or invocation; a host that synchronously claims or cancels that request prevents validation, GraphQL invocation, and synthetic result emission.
The reserved framework operation helper matches only `causeway.security.LogoutMenu#logout`, so similarly labelled application actions remain ordinary domain actions.
-A host can assign an `actionLabel` function to `<cw-menubars>` or one bar, or configure the same function through `configureCausewayMenubarWidgets`, to return bounded presentation text for an exact menu action.
-The callback cannot change identity, hierarchy, order, visibility, disabled state, metadata, or activation, and the same immutable projection feeds native and Vaadin controls.
+A host can assign `menuLabel`, `actionLabel`, and `actionAppearance` functions to `<cw-menubars>` or one bar, or configure the same functions through `configureCausewayMenubarWidgets`, to return bounded presentation values for an exact menu or action.
+`menuLabel` receives immutable role, menu-index, and authored-label context, while action callbacks receive exact logical service type, action ID, and authored label.
+Appearance values are bounded inert tokens exposed through `data-causeway-action-appearance` on native controls and Vaadin adapter content.
+These callbacks cannot change identity, hierarchy, order, visibility, disabled state, metadata, or activation, and the same immutable projection feeds native and Vaadin controls.
+Authentication-neutral components never discover a username or infer Logout from a label; an authenticated host can map its current display identity and exact `causeway.security.LogoutMenu#logout` presentation explicitly.
Selecting an enabled menu action closes its transient menu before interaction presentation, clicking outside closes an expanded menu without moving focus, and cancellation restores focus to the visible originating disclosure.
Each non-empty bar renders a labelled `nav` landmark.
@@ -265,7 +268,7 @@ Empty, malformed, unsupported, stale, disconnected, hidden, family-failed, or ex
Menu Bar overflow preserves source order and every visible authorized action at narrow widths, while responsive transitions reuse current Causeway state without another semantic request.
The composite and standalone bars expose `refresh()` to start a new authorization-sensitive and locale-sensitive generation and reject superseded metadata, resource, and service-state responses.
-Stable hooks include `data-menu-state`, `data-causeway-bar`, `data-causeway-bar-role`, `data-causeway-menu`, `data-causeway-menu-section`, `data-causeway-service-action`, `data-service-logical-type`, `data-action-id`, inert presentation-hint data, and the `causeway-menubar*`, `causeway-menu*`, and `causeway-service-action*` classes.
+Stable hooks include `data-menu-state`, `data-causeway-bar`, `data-causeway-bar-role`, `data-causeway-menu`, `data-causeway-menu-section`, `data-causeway-service-action`, `data-service-logical-type`, `data-action-id`, `data-causeway-action-appearance`, inert presentation-hint data, and the `causeway-menubar*`, `causeway-menu*`, and `causeway-service-action*` classes.
`causeway-menubars-state-change` publishes lifecycle state.
`causeway-menubars-diagnostic` publishes bounded redacted failures without response bodies, credentials, authorization rules, submitted values, or remote exception text.
Internal presentation adds `data-causeway-menubar-presentation`, `data-causeway-menubar-responsive`, and bounded `data-causeway-menubar-fallback` diagnostics without making raw `vaadin-menu-bar`, item objects, item-selected events, properties, methods, theme internals, or Shadow DOM supported application APIs.
diff --git a/viewers/webcomponents/foundation/src/component-styles.css b/viewers/webcomponents/foundation/src/component-styles.css
index f7b6555c101..980f950865f 100644
--- a/viewers/webcomponents/foundation/src/component-styles.css
+++ b/viewers/webcomponents/foundation/src/component-styles.css
@@ -940,6 +940,23 @@ cw-menubar-control vaadin-menu-bar-button[focused] {
.causeway-service-action-control {
inline-size: 100%;
}
+.causeway-service-action-control[data-action-appearance="sign-out"],
+.causeway-menubar-item-label[data-causeway-action-appearance="sign-out"],
+vaadin-menu-bar-item[data-causeway-action-appearance="sign-out"] {
+ border: var(--causeway-sign-out-border, 1px solid currentColor);
+ border-radius: var(--causeway-sign-out-radius, 0.25rem);
+}
+.causeway-menubar-item-label[data-causeway-action-appearance="sign-out"],
+vaadin-menu-bar-item[data-causeway-action-appearance="sign-out"] {
+ box-sizing: border-box;
+ inline-size: 100%;
+ padding: var(--causeway-sign-out-padding, 0.35rem 0.6rem);
+}
+.causeway-service-action-control[data-action-appearance="sign-out"]:focus-visible,
+vaadin-menu-bar-item[data-causeway-action-appearance="sign-out"]:focus-within {
+ outline: var(--causeway-menubar-focus, 0.2rem solid LinkText);
+ outline-offset: 0.1rem;
+}
.causeway-menu-icon {
display: inline-block;
inline-size: 0.75rem;
diff --git a/viewers/webcomponents/foundation/src/component-styles.mjs b/viewers/webcomponents/foundation/src/component-styles.mjs
index 9242e7d1ccc..90df57ff939 100644
--- a/viewers/webcomponents/foundation/src/component-styles.mjs
+++ b/viewers/webcomponents/foundation/src/component-styles.mjs
@@ -941,6 +941,23 @@ cw-menubar-control vaadin-menu-bar-button[focused] {
.causeway-service-action-control {
inline-size: 100%;
}
+.causeway-service-action-control[data-action-appearance="sign-out"],
+.causeway-menubar-item-label[data-causeway-action-appearance="sign-out"],
+vaadin-menu-bar-item[data-causeway-action-appearance="sign-out"] {
+ border: var(--causeway-sign-out-border, 1px solid currentColor);
+ border-radius: var(--causeway-sign-out-radius, 0.25rem);
+}
+.causeway-menubar-item-label[data-causeway-action-appearance="sign-out"],
+vaadin-menu-bar-item[data-causeway-action-appearance="sign-out"] {
+ box-sizing: border-box;
+ inline-size: 100%;
+ padding: var(--causeway-sign-out-padding, 0.35rem 0.6rem);
+}
+.causeway-service-action-control[data-action-appearance="sign-out"]:focus-visible,
+vaadin-menu-bar-item[data-causeway-action-appearance="sign-out"]:focus-within {
+ outline: var(--causeway-menubar-focus, 0.2rem solid LinkText);
+ outline-offset: 0.1rem;
+}
.causeway-menu-icon {
display: inline-block;
inline-size: 0.75rem;
diff --git a/viewers/webcomponents/foundation/src/menubar-element.mjs b/viewers/webcomponents/foundation/src/menubar-element.mjs
index cd95f48d351..f9f397437e4 100644
--- a/viewers/webcomponents/foundation/src/menubar-element.mjs
+++ b/viewers/webcomponents/foundation/src/menubar-element.mjs
@@ -54,7 +54,9 @@ export class CausewayMenubarElement extends HTMLElementBase {
this._client = null;
this._fetchImpl = null;
this._excludeAction = null;
+ this._menuLabel = null;
this._actionLabel = null;
+ this._actionAppearance = null;
this.lastDiagnosticGeneration = -1;
this._currentState = null;
this._currentBar = null;
@@ -121,6 +123,15 @@ export class CausewayMenubarElement extends HTMLElementBase {
this.#renderCurrentReadyState();
}
+ get menuLabel() {
+ return this._menuLabel;
+ }
+
+ set menuLabel(value) {
+ this._menuLabel = typeof value === 'function' ? value : null;
+ this.#renderCurrentReadyState();
+ }
+
get actionLabel() {
return this._actionLabel;
}
@@ -130,6 +141,15 @@ export class CausewayMenubarElement extends HTMLElementBase {
this.#renderCurrentReadyState();
}
+ get actionAppearance() {
+ return this._actionAppearance;
+ }
+
+ set actionAppearance(value) {
+ this._actionAppearance = typeof value === 'function' ? value : null;
+ this.#renderCurrentReadyState();
+ }
+
connectedCallback() {
globalThis.document?.addEventListener?.('click', this.onOutsideClick);
globalThis.document?.addEventListener?.(CAUSEWAY_MENUBAR_WIDGET_POLICY_EVENT, this.onPolicyChange);
@@ -230,12 +250,18 @@ export class CausewayMenubarElement extends HTMLElementBase {
const widgetPolicy = causewayMenubarWidgetConfiguration();
const instanceExcludeAction = this._excludeAction;
const globalExcludeAction = widgetPolicy.excludeAction;
+ const instanceMenuLabel = this._menuLabel;
+ const globalMenuLabel = widgetPolicy.menuLabel;
const instanceActionLabel = this._actionLabel;
const globalActionLabel = widgetPolicy.actionLabel;
+ const instanceActionAppearance = this._actionAppearance;
+ const globalActionAppearance = widgetPolicy.actionAppearance;
const projection = projectCausewayMenuBar(bar, {
generation: state.generation,
excludeAction: detail => instanceExcludeAction?.(detail) === true || globalExcludeAction?.(detail) === true,
- actionLabel: detail => instanceActionLabel?.(detail) ?? globalActionLabel?.(detail)
+ menuLabel: detail => instanceMenuLabel?.(detail) ?? globalMenuLabel?.(detail),
+ actionLabel: detail => instanceActionLabel?.(detail) ?? globalActionLabel?.(detail),
+ actionAppearance: detail => instanceActionAppearance?.(detail) ?? globalActionAppearance?.(detail)
});
const qualification = qualifyCausewayMenuBar({
role: this.role,
@@ -586,8 +612,8 @@ function renderAction(action, sequence, role, menuIndex, sectionIndex, actionInd
const reason = action.disabled
? `<span class="causeway-disabled-reason causeway-visually-hidden" id="${reasonId}">${escapeHtml(disabledReason)}</span>`
: '';
- return `<div class="causeway-service-action${tooltip ? ' causeway-action-control-tooltip' : ''}" data-causeway-service-action-region${dataHint('css-hint', action.cssHint)}${dataHint('icon-hint', action.iconHint)}${tooltipAttributes}>
- <button class="causeway-service-action-control" type="button" data-causeway-service-action data-service-logical-type="${escapeHtml(action.serviceLogicalTypeName)}" data-action-id="${escapeHtml(action.actionId)}"${disabled}${describedBy}>${renderActionContent(presentation.name, presentation.icon)}</button>${description}${reason}
+ return `<div class="causeway-service-action${tooltip ? ' causeway-action-control-tooltip' : ''}" data-causeway-service-action-region${dataHint('action-appearance', action.appearance)}${dataHint('css-hint', action.cssHint)}${dataHint('icon-hint', action.iconHint)}${tooltipAttributes}>
+ <button class="causeway-service-action-control" type="button" data-causeway-service-action data-service-logical-type="${escapeHtml(action.serviceLogicalTypeName)}" data-action-id="${escapeHtml(action.actionId)}"${dataHint('action-appearance', action.appearance)}${disabled}${describedBy}>${renderActionContent(presentation.name, presentation.icon)}</button>${description}${reason}
</div>`;
}
diff --git a/viewers/webcomponents/foundation/src/menubar-projection.mjs b/viewers/webcomponents/foundation/src/menubar-projection.mjs
index cdb0e3ba531..af20be77b29 100644
--- a/viewers/webcomponents/foundation/src/menubar-projection.mjs
+++ b/viewers/webcomponents/foundation/src/menubar-projection.mjs
@@ -12,7 +12,13 @@ const MAX_SECTIONS = 128;
const MAX_ACTIONS = 1024;
const MAX_TEXT = 512;
-export function projectCausewayMenuBar(bar, {generation = 0, excludeAction = null, actionLabel = null} = {}) {
+export function projectCausewayMenuBar(bar, {
+ generation = 0,
+ excludeAction = null,
+ menuLabel = null,
+ actionLabel = null,
+ actionAppearance = null
+} = {}) {
const role = ROLES.includes(bar?.role) ? bar.role : null;
if (!role) return rejected('role-unsupported', generation);
if (!Number.isSafeInteger(generation) || generation < 0) return rejected('generation-invalid', 0, role);
@@ -39,11 +45,13 @@ export function projectCausewayMenuBar(bar, {generation = 0, excludeAction = nul
if (seenActions.has(semanticIdentity)) throw projectionError('action-identity-duplicate');
seenActions.add(semanticIdentity);
const key = `${generation}:${role}:${menuIndex}:${sectionIndex}:${actionIndex}`;
- const label = projectedActionLabel(actionLabel, {
+ const presentationDetail = Object.freeze({
serviceLogicalTypeName,
actionId,
label: boundedText(action?.label || actionId)
});
+ const label = projectedActionLabel(actionLabel, presentationDetail);
+ const appearance = projectedActionAppearance(actionAppearance, presentationDetail);
const descriptor = Object.freeze({
kind: 'action',
key,
@@ -54,6 +62,7 @@ export function projectCausewayMenuBar(bar, {generation = 0, excludeAction = nul
serviceLogicalTypeName,
actionId,
label,
+ ...(appearance ? {appearance} : {}),
description: boundedText(action?.description),
iconHint: boundedText(action?.iconHint, 128),
iconPosition: action?.iconPosition === 'RIGHT' ? 'RIGHT' : 'LEFT',
@@ -79,11 +88,12 @@ export function projectCausewayMenuBar(bar, {generation = 0, excludeAction = nul
generation
});
}).filter(section => section.actions.length > 0);
+ const authoredLabel = boundedText(menu?.label || `Menu ${menuIndex + 1}`);
return Object.freeze({
kind: 'menu',
role,
menuIndex,
- label: boundedText(menu?.label || `Menu ${menuIndex + 1}`),
+ label: projectedMenuLabel(menuLabel, Object.freeze({role, menuIndex, label: authoredLabel})),
description: boundedText(menu?.description),
iconHint: boundedText(menu?.iconHint, 128),
sections: Object.freeze(sections),
@@ -127,6 +137,7 @@ export function createVaadinMenuItems(projection) {
disabled: action.disabled,
causewayKind: 'action',
causewayKey: action.key,
+ causewayActionAppearance: action.appearance,
causewayIconHint: action.iconHint,
causewayIconPosition: action.iconPosition,
causewayDisabledReason: action.disabledReason
@@ -154,16 +165,36 @@ function isExcludedAction(predicate, action) {
}
}
+function projectedMenuLabel(mapper, detail) {
+ if (typeof mapper !== 'function') return detail.label;
+ try {
+ const mapped = mapper(detail);
+ return typeof mapped === 'string' ? boundedText(mapped) || detail.label : detail.label;
+ } catch (_error) {
+ return detail.label;
+ }
+}
+
function projectedActionLabel(mapper, detail) {
if (typeof mapper !== 'function') return detail.label;
try {
- const mapped = mapper(Object.freeze({...detail}));
+ const mapped = mapper(detail);
return typeof mapped === 'string' ? boundedText(mapped) || detail.label : detail.label;
} catch (_error) {
return detail.label;
}
}
+function projectedActionAppearance(mapper, detail) {
+ if (typeof mapper !== 'function') return '';
+ try {
+ const mapped = mapper(detail);
+ return typeof mapped === 'string' && /^[-_A-Za-z0-9]{1,64}$/.test(mapped) ? mapped : '';
+ } catch (_error) {
+ return '';
+ }
+}
+
function rejected(reason, generation, role = null) {
return Object.freeze({accepted: false, reason, role, generation, menus: Object.freeze([]), actions: Object.freeze({}), actionCount: 0});
}
diff --git a/viewers/webcomponents/foundation/src/menubar-widget.mjs b/viewers/webcomponents/foundation/src/menubar-widget.mjs
index 7c445113931..734086f3290 100644
--- a/viewers/webcomponents/foundation/src/menubar-widget.mjs
+++ b/viewers/webcomponents/foundation/src/menubar-widget.mjs
@@ -23,7 +23,9 @@ let configuration = Object.freeze({
moduleUrl: DEFAULT_MODULE_URL,
definitionTimeoutMs: DEFAULT_DEFINITION_TIMEOUT_MS,
excludeAction: null,
- actionLabel: null
+ menuLabel: null,
+ actionLabel: null,
+ actionAppearance: null
});
let modulePromise = null;
let failed = false;
@@ -44,7 +46,9 @@ export function configureCausewayMenubarWidgets(options = {}) {
moduleUrl: options.moduleUrl ? safeModuleUrl(options.moduleUrl) : DEFAULT_MODULE_URL,
definitionTimeoutMs: boundedTimeout(options.definitionTimeoutMs),
excludeAction: typeof options.excludeAction === 'function' ? options.excludeAction : null,
- actionLabel: typeof options.actionLabel === 'function' ? options.actionLabel : null
+ menuLabel: typeof options.menuLabel === 'function' ? options.menuLabel : null,
+ actionLabel: typeof options.actionLabel === 'function' ? options.actionLabel : null,
+ actionAppearance: typeof options.actionAppearance === 'function' ? options.actionAppearance : null
});
modulePromise = null;
failed = false;
@@ -245,6 +249,7 @@ function materializeMenuItems(items) {
if (item.causewayKind === 'action' || item.title || item.causewayIconHint || item.causewayDisabledReason) {
const menuItem = document.createElement('vaadin-menu-bar-item');
if (item.causewayKey) menuItem.dataset.causewayKey = item.causewayKey;
+ if (item.causewayActionAppearance) menuItem.dataset.causewayActionAppearance = item.causewayActionAppearance;
const label = document.createElement('span');
const presentation = normalizeActionPresentation({
name: item.text,
@@ -253,6 +258,7 @@ function materializeMenuItems(items) {
cssClassFaPosition: item.causewayIconPosition
});
label.className = `causeway-menubar-item-label${item.title ? ' causeway-action-control-tooltip' : ''}`;
+ if (item.causewayActionAppearance) label.dataset.causewayActionAppearance = item.causewayActionAppearance;
appendActionContent(label, item.text, presentation.icon);
if (item.title) label.dataset.tooltip = item.title;
if (item.causewayIconHint) label.dataset.iconHint = item.causewayIconHint;
diff --git a/viewers/webcomponents/foundation/src/menubars-element.mjs b/viewers/webcomponents/foundation/src/menubars-element.mjs
index b783430d48e..dd620169151 100644
--- a/viewers/webcomponents/foundation/src/menubars-element.mjs
+++ b/viewers/webcomponents/foundation/src/menubars-element.mjs
@@ -41,7 +41,9 @@ export class CausewayMenubarsElement extends HTMLElementBase {
this._client = null;
this._fetchImpl = null;
this._excludeAction = null;
+ this._menuLabel = null;
this._actionLabel = null;
+ this._actionAppearance = null;
this._context = null;
this._release = null;
this.lastDiagnosticGeneration = -1;
@@ -81,6 +83,15 @@ export class CausewayMenubarsElement extends HTMLElementBase {
for (const child of this.#directBarChildren()) child.excludeAction = this._excludeAction;
}
+ get menuLabel() {
+ return this._menuLabel;
+ }
+
+ set menuLabel(value) {
+ this._menuLabel = typeof value === 'function' ? value : null;
+ for (const child of this.#directBarChildren()) child.menuLabel = this._menuLabel;
+ }
+
get actionLabel() {
return this._actionLabel;
}
@@ -90,6 +101,15 @@ export class CausewayMenubarsElement extends HTMLElementBase {
for (const child of this.#directBarChildren()) child.actionLabel = this._actionLabel;
}
+ get actionAppearance() {
+ return this._actionAppearance;
+ }
+
+ set actionAppearance(value) {
+ this._actionAppearance = typeof value === 'function' ? value : null;
+ for (const child of this.#directBarChildren()) child.actionAppearance = this._actionAppearance;
+ }
+
get context() {
return this._context;
}
@@ -161,7 +181,9 @@ export class CausewayMenubarsElement extends HTMLElementBase {
#provideContextToDeclarativeBars() {
for (const child of this.#directBarChildren()) {
child.excludeAction = this._excludeAction;
+ child.menuLabel = this._menuLabel;
child.actionLabel = this._actionLabel;
+ child.actionAppearance = this._actionAppearance;
child.context = this._context;
}
}
@@ -180,7 +202,9 @@ export class CausewayMenubarsElement extends HTMLElementBase {
if (bar) {
bar.setAttribute('data-causeway-bar-order', String(index));
bar.excludeAction = this._excludeAction;
+ bar.menuLabel = this._menuLabel;
bar.actionLabel = this._actionLabel;
+ bar.actionAppearance = this._actionAppearance;
bar.context = this._context;
if (!present && bar.getAttribute('data-causeway-generated') === 'menubar') {
this.removeChild(bar);
diff --git a/viewers/webcomponents/foundation/test/component-styles.test.mjs b/viewers/webcomponents/foundation/test/component-styles.test.mjs
index 29b7c9a52c1..f36bf02820a 100644
--- a/viewers/webcomponents/foundation/test/component-styles.test.mjs
+++ b/viewers/webcomponents/foundation/test/component-styles.test.mjs
@@ -68,6 +68,11 @@ test('object-link icons remain bounded, decorative markup is themeable and heade
assert.match(CAUSEWAY_COMPONENT_STYLES, /\.causeway-breadcrumbs \.causeway-object-link-identity,\s+\.causeway-object-header-link \.causeway-object-link-identity \{\s+display: none;/);
});
+test('sign-out appearance remains visibly bounded in native and Vaadin menu controls', () => {
+ assert.match(CAUSEWAY_COMPONENT_STYLES, /\.causeway-service-action-control\[data-action-appearance="sign-out"\],[\s\S]*?vaadin-menu-bar-item\[data-causeway-action-appearance="sign-out"\] \{[\s\S]*?border: var\(--causeway-sign-out-border, 1px solid currentColor\);/);
+ assert.match(CAUSEWAY_COMPONENT_STYLES, /vaadin-menu-bar-item\[data-causeway-action-appearance="sign-out"\]:focus-within \{[\s\S]*?outline: var\(--causeway-menubar-focus, 0\.2rem solid LinkText\);/);
+});
+
test('action icons retain component-owned spacing in native and Vaadin controls', () => {
assert.match(CAUSEWAY_COMPONENT_STYLES, /gap: var\(--causeway-action-icon-gap, 0\.75rem\);/);
assert.match(CAUSEWAY_COMPONENT_STYLES, /cw-action-control > vaadin-button > \.causeway-action-icon:first-child \{\s+margin-inline-end: var\(--causeway-action-icon-gap, 0\.75rem\);/);
diff --git a/viewers/webcomponents/foundation/test/menubar-component.test.mjs b/viewers/webcomponents/foundation/test/menubar-component.test.mjs
index da6e7d640c2..5574e2fd0e9 100644
--- a/viewers/webcomponents/foundation/test/menubar-component.test.mjs
+++ b/viewers/webcomponents/foundation/test/menubar-component.test.mjs
@@ -47,15 +47,18 @@ test('semantic bar renders labelled navigation, disclosures, text-safe hints, di
const plan = applyServiceActionStates(parsed.plan, MENU_ACTION_STATES);
const context = fakeMenuContext(plan);
const bar = new CausewayMenubarPrimaryElement();
+ bar.menuLabel = detail => detail.menuIndex === 0 ? 'sven' : undefined;
bar.actionLabel = detail => detail.actionId === 'welcomeMessage' ? 'Sign out' : undefined;
+ bar.actionAppearance = detail => detail.actionId === 'welcomeMessage' ? 'sign-out' : undefined;
bar.context = context;
document.body.appendChild(bar);
const markup = renderMarkup(bar);
assert.match(markup, /<nav[^>]+aria-label="Primary application menu"/);
- assert.match(markup, /data-causeway-menu-disclosure/);
+ assert.match(markup, />sven<\/button>/);
assert.match(markup, /aria-expanded="false"/);
assert.match(markup, /data-icon-hint="fa-building"/);
+ assert.match(markup, /data-action-appearance="sign-out"/);
assert.match(markup, /<span class="causeway-action-label">Sign out<\/span><i class="causeway-action-icon fa-solid fa-message"/);
assert.match(markup, /disabled aria-disabled="true"/);
assert.match(markup, /data-tooltip="Performs an administrative operation\.\n\nAvailable to administrators only\."/);
@@ -196,9 +199,13 @@ test('composite preserves declarative bars, generates only missing effective rol
const composite = new CausewayMenubarsElement();
const excludeWelcome = detail => detail.serviceLogicalTypeName === 'causeway.webcomponents.sample.SampleMenu'
&& detail.actionId === 'welcomeMessage';
+ const menuLabel = detail => detail.role === 'tertiary' ? 'sven' : undefined;
const actionLabel = detail => detail.actionId === 'dailyReport' ? 'Daily summary' : undefined;
+ const actionAppearance = detail => detail.actionId === 'dailyReport' ? 'outlined' : undefined;
composite.excludeAction = excludeWelcome;
+ composite.menuLabel = menuLabel;
composite.actionLabel = actionLabel;
+ composite.actionAppearance = actionAppearance;
composite.client = new CausewayGraphQLClient({executor});
let fetchCount = 0;
composite.fetchImpl = async () => {
@@ -220,7 +227,9 @@ test('composite preserves declarative bars, generates only missing effective rol
assert.equal(bars.filter(child => child.localName === CausewayElementName.MENUBAR_PRIMARY).length, 1);
assert.equal(bars.find(child => child.localName === CausewayElementName.MENUBAR_PRIMARY), declarativePrimary);
assert.ok(bars.every(child => child.excludeAction === excludeWelcome));
+ assert.ok(bars.every(child => child.menuLabel === menuLabel));
assert.ok(bars.every(child => child.actionLabel === actionLabel));
+ assert.ok(bars.every(child => child.actionAppearance === actionAppearance));
assert.doesNotMatch(bars.map(renderMarkup).join(''), /welcomeMessage|Welcome Message/);
assert.equal(fetchCount, 1);
assert.equal(executor.applicationCalls.length, 1);
diff --git a/viewers/webcomponents/foundation/test/menubar-projection.test.mjs b/viewers/webcomponents/foundation/test/menubar-projection.test.mjs
index 7f8b90b98f8..130f44c2f1a 100644
--- a/viewers/webcomponents/foundation/test/menubar-projection.test.mjs
+++ b/viewers/webcomponents/foundation/test/menubar-projection.test.mjs
@@ -105,24 +105,49 @@ test('host label mapping changes presentation without changing semantic action s
});
const projection = projectCausewayMenuBar(source, {
generation: 5,
+ menuLabel: menu => menu.role === 'primary' && menu.label === 'Administration' ? 'sven' : undefined,
actionLabel: action => action.serviceLogicalTypeName === 'causeway.security.LogoutMenu'
- && action.actionId === 'logout' ? 'Sign out' : undefined
+ && action.actionId === 'logout' ? 'Sign out' : undefined,
+ actionAppearance: action => action.serviceLogicalTypeName === 'causeway.security.LogoutMenu'
+ && action.actionId === 'logout' ? 'sign-out' : undefined
});
const logout = Object.values(projection.actions).find(action => action.actionId === 'logout');
+ assert.equal(projection.menus[0].label, 'sven');
assert.equal(logout.label, 'Sign out');
+ assert.equal(logout.appearance, 'sign-out');
assert.equal(logout.serviceLogicalTypeName, 'causeway.security.LogoutMenu');
assert.equal(logout.role, 'primary');
assert.equal(projection.menus[0].sections[1].actions.at(-1), logout);
- assert.equal(JSON.stringify(createVaadinMenuItems(projection)).includes('Sign out'), true);
+ const vaadinItems = createVaadinMenuItems(projection);
+ assert.equal(JSON.stringify(vaadinItems).includes('Sign out'), true);
+ assert.equal(vaadinItems[0].children.at(-1).causewayActionAppearance, 'sign-out');
assert.equal(projection.menus[0].sections[0].actions[0].label, 'Find people');
+ assert.equal(projection.menus[0].sections[0].actions[0].appearance, undefined);
});
-test('defective host label mapping preserves bounded authoritative labels', () => {
- const thrown = projectCausewayMenuBar(bar(), {actionLabel: () => { throw new Error('host failure'); }});
- const unsupported = projectCausewayMenuBar(bar(), {actionLabel: () => ({label: '<script>'})});
- const bounded = projectCausewayMenuBar(bar(), {actionLabel: () => `Sign\u0000 out${'!'.repeat(600)}`});
+test('defective host presentation mapping preserves bounded authoritative presentation', () => {
+ const thrown = projectCausewayMenuBar(bar(), {
+ menuLabel: () => { throw new Error('host failure'); },
+ actionLabel: () => { throw new Error('host failure'); },
+ actionAppearance: () => { throw new Error('host failure'); }
+ });
+ const unsupported = projectCausewayMenuBar(bar(), {
+ menuLabel: () => ({label: '<script>'}),
+ actionLabel: () => ({label: '<script>'}),
+ actionAppearance: () => 'not valid!'
+ });
+ const bounded = projectCausewayMenuBar(bar(), {
+ menuLabel: () => `sven\u0000${'!'.repeat(600)}`,
+ actionLabel: () => `Sign\u0000 out${'!'.repeat(600)}`
+ });
+ assert.equal(thrown.menus[0].label, 'Administration');
assert.equal(thrown.menus[0].sections[0].actions[0].label, 'Find people');
+ assert.equal(thrown.menus[0].sections[0].actions[0].appearance, undefined);
+ assert.equal(unsupported.menus[0].label, 'Administration');
assert.equal(unsupported.menus[0].sections[0].actions[0].label, 'Find people');
+ assert.equal(unsupported.menus[0].sections[0].actions[0].appearance, undefined);
+ assert.equal(bounded.menus[0].label.includes('\u0000'), false);
+ assert.equal(bounded.menus[0].label.length, 512);
assert.equal(bounded.menus[0].sections[0].actions[0].label.includes('\u0000'), false);
assert.equal(bounded.menus[0].sections[0].actions[0].label.length, 512);
});
diff --git a/viewers/webcomponents/foundation/test/menubar-widget.test.mjs b/viewers/webcomponents/foundation/test/menubar-widget.test.mjs
index 51c7b63e448..2675329bef4 100644
--- a/viewers/webcomponents/foundation/test/menubar-widget.test.mjs
+++ b/viewers/webcomponents/foundation/test/menubar-widget.test.mjs
@@ -28,7 +28,10 @@ function projection(generation = 1) {
{serviceLogicalTypeName: 'demo.People', actionId: 'find', label: 'Find people'},
{serviceLogicalTypeName: 'demo.People', actionId: 'remove', label: 'Remove', disabled: 'Not permitted'}
]}]}]
- }, {generation});
+ }, {
+ generation,
+ actionAppearance: action => action.actionId === 'find' ? 'sign-out' : undefined
+ });
}
async function connect(adapter) {
@@ -75,6 +78,8 @@ test('qualified adapter loads once and selects only current enabled leaves', asy
assert.equal(section.component.getAttribute('role'), 'separator');
assert.equal(section.component.getAttribute('aria-label'), 'People');
assert.equal(enabled.component.localName, 'vaadin-menu-bar-item');
+ assert.equal(enabled.component.dataset.causewayActionAppearance, 'sign-out');
+ assert.equal(enabled.component.childNodes[0].dataset.causewayActionAppearance, 'sign-out');
assert.equal(disabled.component.localName, 'vaadin-menu-bar-item');
assert.equal(disabled.component.childNodes[0].getAttribute('aria-label'), 'Remove. Unavailable: Not permitted');
assert.equal(disabled.component.childNodes[0].dataset.disabledReason, 'Not permitted');
diff --git a/viewers/webcomponents/htmx-security-secman/README.adoc b/viewers/webcomponents/htmx-security-secman/README.adoc
index 0ef71dc42d8..d66ef9fd88e 100644
--- a/viewers/webcomponents/htmx-security-secman/README.adoc
+++ b/viewers/webcomponents/htmx-security-secman/README.adoc
@@ -100,7 +100,7 @@ Local plain-HTTP development cannot use a `Secure` cookie, so production cookie
The login page is server rendered and makes no GraphQL request before authentication.
Spring Security owns credential processing, request caching, session-identifier migration, security-context persistence, CSRF, and logout invalidation.
Only canonical same-origin GET routes beneath the configured HTMX base path are restored after login.
-The HTMX host owns login presentation, session-expiry navigation, exact interception of `causeway.security.LogoutMenu#logout`, its **Sign out** menu label, and the non-visual native POST logout form.
+The HTMX host owns login presentation, session-expiry navigation, the authenticated username used as its utility-menu title, exact interception of `causeway.security.LogoutMenu#logout`, its **Sign out** label and outlined appearance, and the non-visual native POST logout form.
When the exact action is authorized, it remains in the GraphQL-authoritative tertiary menu position and activation submits the form before GraphQL invocation; the shell renders no separate visible username or logout block.
Other tertiary actions remain subject to ordinary menu layout and SecMan authorization, so applications must grant any regular-user or configuration roles needed to preserve controls such as **Me** and **Configuration**.
That menu control remains independent of typed `LocalResourcePathValue` action results: secure logout uses current CSRF evidence and Spring Security session invalidation, while local-resource results are only safe same-origin navigation.
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java
index a840a42ce3a..d2a82fbcdfc 100644
--- a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java
@@ -144,12 +144,14 @@ private static String authenticationMetadata(final Optional<HtmxAuthenticationSh
.sorted()
.collect(java.util.stream.Collectors.joining(","));
return """
+ <meta name="causeway-auth-username" content="%s">
<meta name="causeway-auth-login" content="%s">
<meta name="causeway-auth-csrf-header" content="%s">
<meta name="causeway-auth-csrf-parameter" content="%s">
<meta name="causeway-auth-csrf-token" content="%s">
<meta name="causeway-auth-excluded-actions" content="%s">
""".formatted(
+ escape(value.username()),
escape(value.loginPath()),
escape(value.csrfHeaderName()),
escape(value.csrfParameterName()),
diff --git a/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/authentication-policy.mjs b/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/authentication-policy.mjs
index 68926bc20e3..e8e8f66ef98 100644
--- a/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/authentication-policy.mjs
+++ b/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/authentication-policy.mjs
@@ -19,6 +19,7 @@
export function readAuthenticationMetadata(document) {
const value = name => document.querySelector(`meta[name="${name}"]`)?.content ?? '';
+ const username = value('causeway-auth-username').trim();
const loginPath = value('causeway-auth-login');
const csrfHeaderName = value('causeway-auth-csrf-header');
const csrfParameterName = value('causeway-auth-csrf-parameter');
@@ -27,6 +28,7 @@ export function readAuthenticationMetadata(document) {
return null;
}
return Object.freeze({
+ username,
loginPath,
csrfHeaderName,
csrfParameterName,
@@ -56,10 +58,24 @@ export function isExcludedAction(authentication, detail) {
return authentication?.excludedActions.has(identity) ?? false;
}
+export function authenticationMenuLabel(authentication, detail) {
+ return authentication?.username
+ && detail?.role === 'tertiary'
+ && detail?.label === 'Account'
+ ? authentication.username
+ : undefined;
+}
+
export function authenticationActionLabel(authentication, detail) {
+ return isFrameworkLogout(authentication, detail) ? 'Sign out' : undefined;
+}
+
+export function authenticationActionAppearance(authentication, detail) {
+ return isFrameworkLogout(authentication, detail) ? 'sign-out' : undefined;
+}
+
+function isFrameworkLogout(authentication, detail) {
return isExcludedAction(authentication, detail)
&& detail?.serviceLogicalTypeName === 'causeway.security.LogoutMenu'
- && detail?.actionId === 'logout'
- ? 'Sign out'
- : undefined;
+ && detail?.actionId === 'logout';
}
diff --git a/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.mjs b/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.mjs
index 8989f38e772..8a341f87c3e 100644
--- a/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.mjs
+++ b/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.mjs
@@ -49,7 +49,9 @@ import {
standaloneCollectionPresentation
} from '../causeway-webcomponents/standalone-collection-presentation.mjs';
import {
+ authenticationActionAppearance,
authenticationActionLabel,
+ authenticationMenuLabel,
csrfHeaders,
isExcludedAction,
isUnsafeMethod,
@@ -147,7 +149,9 @@ configureCausewayMenubarWidgets({
excludeAction: detail => isFrameworkLogoutAction(detail)
? !Boolean(authentication && isExcludedAction(authentication, detail))
: Boolean(authentication && isExcludedAction(authentication, detail)),
- actionLabel: detail => authenticationActionLabel(authentication, detail)
+ menuLabel: detail => authenticationMenuLabel(authentication, detail),
+ actionLabel: detail => authenticationActionLabel(authentication, detail),
+ actionAppearance: detail => authenticationActionAppearance(authentication, detail)
});
let activeRequest = null;
let navigationGeneration = 0;
diff --git a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
index 47be0c7d829..e84c6e24b30 100644
--- a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
+++ b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
@@ -436,6 +436,7 @@ void authenticatedFullShellRendersBoundedCsrfLogoutContractAndExactPolicyMetadat
final var response = controller.route(request("/htmx", "", false));
assertThat(response.getBody())
+ .contains("<meta name=\"causeway-auth-username\" content=\"Sven & Co\">")
.contains("<meta name=\"causeway-auth-login\" content=\"/htmx/login\">")
.contains("<meta name=\"causeway-auth-csrf-header\" content=\"X-CSRF-TOKEN\">")
.contains("<meta name=\"causeway-auth-csrf-token\" content=\"token<bounded>\">")
diff --git a/viewers/webcomponents/htmx/test/authentication-policy.test.mjs b/viewers/webcomponents/htmx/test/authentication-policy.test.mjs
index 575bf400485..39da15372a8 100644
--- a/viewers/webcomponents/htmx/test/authentication-policy.test.mjs
+++ b/viewers/webcomponents/htmx/test/authentication-policy.test.mjs
@@ -21,7 +21,9 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
+ authenticationActionAppearance,
authenticationActionLabel,
+ authenticationMenuLabel,
csrfHeaders,
isExcludedAction,
isUnsafeMethod,
@@ -30,6 +32,7 @@ import {
} from '../src/main/resources/META-INF/resources/causeway-htmx/authentication-policy.mjs';
const metadata = {
+ 'causeway-auth-username': 'sven',
'causeway-auth-login': '/htmx/login',
'causeway-auth-csrf-header': 'X-CSRF-TOKEN',
'causeway-auth-csrf-parameter': '_csrf',
@@ -51,6 +54,7 @@ function documentWith(values = metadata, buttons = []) {
test('authentication metadata is activated only as one complete bounded contract', () => {
const authentication = readAuthenticationMetadata(documentWith());
+ assert.equal(authentication.username, 'sven');
assert.equal(authentication.loginPath, '/htmx/login');
assert.equal(authentication.csrfParameterName, '_csrf');
assert.deepEqual(csrfHeaders(authentication), {'X-CSRF-TOKEN': 'bounded-token'});
@@ -87,8 +91,11 @@ test('legacy exclusion matches only the exact framework service and member', ()
}), false);
});
-test('menu policy relabels only exact host-managed framework Logout', () => {
+test('menu policy labels the utility menu and presents only exact host-managed framework Logout', () => {
const authentication = readAuthenticationMetadata(documentWith());
+ assert.equal(authenticationMenuLabel(authentication, {role: 'tertiary', label: 'Account'}), 'sven');
+ assert.equal(authenticationMenuLabel(authentication, {role: 'primary', label: 'Account'}), undefined);
+ assert.equal(authenticationMenuLabel(null, {role: 'tertiary', label: 'Account'}), undefined);
assert.equal(authenticationActionLabel(authentication, {
serviceLogicalTypeName: 'causeway.security.LogoutMenu', actionId: 'logout', label: 'Logout'
}), 'Sign out');
@@ -98,4 +105,10 @@ test('menu policy relabels only exact host-managed framework Logout', () => {
assert.equal(authenticationActionLabel(null, {
serviceLogicalTypeName: 'causeway.security.LogoutMenu', actionId: 'logout', label: 'Logout'
}), undefined);
+ assert.equal(authenticationActionAppearance(authentication, {
+ serviceLogicalTypeName: 'causeway.security.LogoutMenu', actionId: 'logout'
+ }), 'sign-out');
+ assert.equal(authenticationActionAppearance(authentication, {
+ serviceLogicalTypeName: 'petclinic.LogoutMenu', actionId: 'logout'
+ }), undefined);
});
diff --git a/viewers/webcomponents/sample-htmx-petclinic/README.adoc b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
index e0c7bc42474..986c3af49c8 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/README.adoc
+++ b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
@@ -41,9 +41,9 @@ Run the separate local SecMan-authenticated configuration with:
Open `http://localhost:8080/htmx` and sign in with username `sven` and password `pass`.
This secured configuration enables Spring CSRF protection, consumes the shared presentation-neutral SecMan Spring bridge, and intentionally omits Wicket and bypass security.
-The shared Account layout declares **Me**, **Configuration**, and framework Logout together in the tertiary application-menu hierarchy.
-The deterministic secured user receives the standard regular-user and configuration-access roles, so the secured menu preserves **Me** and **Configuration** while presenting Logout as **Sign out**, whose activation submits the host's non-visual current-CSRF POST form rather than invoking GraphQL.
-The secured shell adds no separate visible username or logout block.
+The shared layout declares **Me**, **Configuration**, and framework Logout together in one tertiary utility-menu hierarchy and omits a duplicate System catch-all.
+The deterministic secured user receives the standard regular-user and configuration-access roles, so host policy labels that menu `sven`, preserves **Me** and **Configuration**, and presents exact Logout as distinctly outlined **Sign out**.
+Sign out activation submits the host's non-visual current-CSRF POST form rather than invoking GraphQL, and the shell adds no separate visible username or logout block.
The secured launcher preserves the same `JAVA_HOME`, `MVN`, and pass-through Maven argument behavior as `run.sh`.
== Headless Playwright acceptance
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
index 657d4c82e97..20c6aedf80b 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
@@ -1571,6 +1571,9 @@ private void waitForMenus() {
}
private void assertOrdinaryTertiaryActions() {
+ assertThat(page.locator("cw-menubar-secondary").count()).isZero();
+ assertThat(page.locator("cw-menubar-tertiary").evaluate(
+ "element => element._projection.menus.map(menu => menu.label).join(',')")).isEqualTo("Account");
assertThat((Boolean) page.locator("cw-menubar-tertiary").evaluate("""
element => {
const actions = Object.values(element._projection?.actions ?? {});
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredPlaywrightTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredPlaywrightTest.java
index bcff350a93a..8d3ba28817c 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredPlaywrightTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredPlaywrightTest.java
@@ -24,6 +24,7 @@
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.options.WaitUntilState;
@@ -125,10 +126,12 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
}
waitForReadyObject(page);
assertThat(page.locator("[data-testid='causeway-shell-user']").count()).isZero();
+ assertThat(page.locator("meta[name='causeway-auth-username']").getAttribute("content")).isEqualTo("sven");
assertThat(page.locator("[data-causeway-logout-form]").getAttribute("hidden")).isNotNull();
page.waitForFunction("() => ['ready','partial-error'].includes(document.querySelector('cw-menubars')?.dataset.menuState)");
page.waitForFunction("() => [...document.querySelectorAll('cw-menubar-primary, cw-menubar-secondary, cw-menubar-tertiary')].filter(element => !element.hidden).every(element => element.dataset.causewayMenubarPresentation?.startsWith('vaadin-') && element.querySelector('cw-menubar-control')?.dataset.widgetState === 'ready')");
assertThat(menuBarRequests).hasSize(1);
+ assertThat(page.locator("cw-menubar-secondary").count()).isZero();
assertThat(page.locator("cw-menubar-tertiary").isVisible())
.as(page.locator("cw-menubar-primary, cw-menubar-secondary, cw-menubar-tertiary")
.evaluateAll("elements => elements.map(element => ({role: element.role, hidden: element.hidden, actions: Object.values(element._projection?.actions ?? {}).map(action => `${action.serviceLogicalTypeName}#${action.actionId}:${action.label}`)}))").toString())
@@ -141,15 +144,30 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
['causeway.conf.ConfigurationMenu#configuration', 'Configuration'],
['causeway.security.LogoutMenu#logout', 'Sign out']
]);
- return [...expected].every(([identity, label]) => {
- const action = actions.find(candidate => `${candidate.serviceLogicalTypeName}#${candidate.actionId}` === identity);
- return action?.label === label && action.role === 'tertiary';
- });
+ const menuLabels = element._projection?.menus?.map(menu => menu.label) ?? [];
+ return menuLabels.length === 1 && menuLabels[0] === 'sven'
+ && [...expected].every(([identity, label]) => {
+ const action = actions.find(candidate => `${candidate.serviceLogicalTypeName}#${candidate.actionId}` === identity);
+ return action?.label === label
+ && action.role === 'tertiary'
+ && (identity !== 'causeway.security.LogoutMenu#logout' || action.appearance === 'sign-out');
+ });
}
"""))
.as(page.locator("cw-menubar-tertiary").evaluate("element => Object.values(element._projection?.actions ?? {}).map(action => `${action.serviceLogicalTypeName}#${action.actionId}:${action.label}:${action.role}`)").toString())
.isTrue();
assertThat(graphQlCsrfHeaders).isNotEmpty().allSatisfy(value -> assertThat(value).isNotBlank());
+ firstVisible(page.locator("vaadin-menu-bar-button")
+ .filter(new Locator.FilterOptions().setHasText("sven"))).click();
+ final var presentedSignOutItem = page.locator("vaadin-menu-bar-item")
+ .filter(new Locator.FilterOptions().setHasText("Sign out")).last();
+ presentedSignOutItem.waitFor();
+ assertThat(presentedSignOutItem.getAttribute("data-causeway-action-appearance")).isEqualTo("sign-out");
+ assertThat(presentedSignOutItem.evaluate("element => getComputedStyle(element).borderTopStyle")).isEqualTo("solid");
+ final var presentedMeItem = page.locator("vaadin-menu-bar-item")
+ .filter(new Locator.FilterOptions().setHasText("Me")).last();
+ assertThat(presentedMeItem.getAttribute("data-causeway-action-appearance")).isNull();
+ page.keyboard().press("Escape");
final var property = page.locator("cw-property[id='knownAs']");
property.locator("[data-causeway-action='edit']").click();
@@ -187,9 +205,9 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
page.waitForURL("**/htmx**");
page.waitForFunction("() => ['ready','partial-error'].includes(document.querySelector('cw-menubars')?.dataset.menuState) && [...document.querySelectorAll('cw-menubar-primary, cw-menubar-secondary, cw-menubar-tertiary')].filter(element => !element.hidden).every(element => element.querySelector('cw-menubar-control')?.dataset.widgetState === 'ready')");
assertThat(menuBarRequests).hasSize(2);
- final var accountMenu = page.locator("vaadin-menu-bar-button")
- .filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("Account")).first();
- accountMenu.click();
+ final var userMenu = firstVisible(page.locator("vaadin-menu-bar-button")
+ .filter(new Locator.FilterOptions().setHasText("sven")));
+ userMenu.click();
final var signOutItem = page.locator("vaadin-menu-bar-item")
.filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("Sign out")).last();
signOutItem.waitFor();
@@ -206,6 +224,16 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
}
}
+ private static Locator firstVisible(final Locator candidates) {
+ for (var index = 0; index < candidates.count(); index++) {
+ var candidate = candidates.nth(index);
+ if (candidate.isVisible()) {
+ return candidate;
+ }
+ }
+ throw new AssertionError("No visible locator among " + candidates.count() + " candidates");
+ }
+
private void waitForReadyObject(final Page page) {
try {
page.waitForFunction("() => ['ready','partial-error'].includes(document.querySelector('[data-testid=\"causeway-route-page\"]')?.dataset.routeState)");
diff --git a/viewers/webcomponents/sample-petclinic-domain/src/main/resources/menubars.layout.xml b/viewers/webcomponents/sample-petclinic-domain/src/main/resources/menubars.layout.xml
index 8d821310c36..988033fdda9 100644
--- a/viewers/webcomponents/sample-petclinic-domain/src/main/resources/menubars.layout.xml
+++ b/viewers/webcomponents/sample-petclinic-domain/src/main/resources/menubars.layout.xml
@@ -36,11 +36,6 @@
</mb:section>
</mb:menu>
</mb:primary>
- <mb:secondary>
- <mb:menu unreferencedActions="true">
- <mb:named>System</mb:named>
- </mb:menu>
- </mb:secondary>
<mb:tertiary>
<mb:menu>
<mb:named>Account</mb:named>
diff --git a/viewers/webcomponents/sample-vue-petclinic/README.adoc b/viewers/webcomponents/sample-vue-petclinic/README.adoc
index a23812830a5..f66a9b08e0a 100644
--- a/viewers/webcomponents/sample-vue-petclinic/README.adoc
+++ b/viewers/webcomponents/sample-vue-petclinic/README.adoc
@@ -53,10 +53,10 @@ Open any `/vue` route and sign in with username `sven` and password `pass`.
The secured launcher preserves the environment and additional Maven argument behavior of `run.sh` while selecting `PetClinicVueSecuredApplication`.
The application restores only safe Vue GET deep links, fetches bounded no-store username and CSRF context before mounting, decorates every GraphQL POST with the current CSRF header, and retains an application-owned non-visual POST logout form.
Invalid, absent, locked, and passwordless accounts have the same public failure, and successful authentication migrates the session identifier.
-The shared Account layout declares **Me**, **Configuration**, and framework Logout together in the tertiary application-menu hierarchy.
-The deterministic secured user receives the standard regular-user and configuration-access roles, so the secured menu preserves **Me** and **Configuration** while presenting exact framework action `causeway.security.LogoutMenu#logout` as **Sign out**.
+The shared layout declares **Me**, **Configuration**, and framework Logout together in one tertiary utility-menu hierarchy and omits a duplicate System catch-all.
+The deterministic secured user receives the standard regular-user and configuration-access roles, so host policy labels that menu `sven`, preserves **Me** and **Configuration**, and presents exact framework action `causeway.security.LogoutMenu#logout` as distinctly outlined **Sign out**.
Host policy claims Sign out activation and submits the native form before GraphQL, while similarly named application actions and typed local-resource results remain independent.
-The secured shell adds no separate visible username or logout block.
+The secured shell adds no separate visible username or logout block, and the Vue result bridge dismisses transient menu-action results after canonical object handoff so **Me** and **Configuration** cannot leave stale result chrome.
== Test
@@ -78,7 +78,7 @@ Install Playwright Chromium on the first run when necessary:
----
The ordinary browser suite covers the HTMX presentation baseline, wide and narrow layout invariants, the four exact-type pages, stable application shell, direct links, refresh, back and forward navigation, generic fallback, invalid and absent objects, semantic navigation and results, menus, keyboard skip navigation, and both toolkit policies.
-The secured browser journey additionally covers generic invalid login, valid deep-link restoration, CSRF-bearing reads and mutation, missing-CSRF rejection, exact host Logout policy, native logout, browser history, and session expiry.
+The secured browser journey additionally covers generic invalid login, valid deep-link restoration, the username-labelled menu under native and Vaadin policies, `UserMemento` and Configuration navigation without stale results, CSRF-bearing reads and mutation, missing-CSRF rejection, exact host Logout policy and appearance, native logout, browser history, and session expiry.
Set `-Dplaywright.headless=false` only when deliberately debugging in a visible browser.
Set `-Dplaywright.chromium.executable=/absolute/path/to/chromium` or `-Dplaywright.chromium.channel=chrome` to use an installed browser.
Any arguments after `--install-browser` are passed to the Maven test invocation.
diff --git a/viewers/webcomponents/sample-vue-petclinic/frontend/src/main.ts b/viewers/webcomponents/sample-vue-petclinic/frontend/src/main.ts
index 043673572b0..9b21f41e5d9 100644
--- a/viewers/webcomponents/sample-vue-petclinic/frontend/src/main.ts
+++ b/viewers/webcomponents/sample-vue-petclinic/frontend/src/main.ts
@@ -66,9 +66,19 @@ const viewer = createCausewayVueViewer({
'petclinic.Visit': VisitPage
},
policies: {
+ menuLabel(detail) {
+ return authenticationContext.value
+ && detail.role === 'tertiary'
+ && detail.label === 'Account'
+ ? authenticationContext.value.username
+ : undefined;
+ },
menuActionLabel(detail) {
return authenticationContext.value && isFrameworkLogoutAction(detail) ? 'Sign out' : undefined;
},
+ menuActionAppearance(detail) {
+ return authenticationContext.value && isFrameworkLogoutAction(detail) ? 'sign-out' : undefined;
+ },
action(detail, claim) {
if (!authenticationContext.value || !isFrameworkLogoutAction(detail)) return;
if (!claim.claim()) return true;
diff --git a/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-BVs2fZoR.js b/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-BVs2fZoR.js
deleted file mode 100644
index 40faf86a5b1..00000000000
--- a/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-BVs2fZoR.js
+++ /dev/null
@@ -1,2 +0,0 @@
-(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))i(s);new MutationObserver(s=>{for(const r of s)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function n(s){const r={};return s.integrity&&(r.integrity=s.integrity),s.referrerPolicy&&(r.referrerPolicy=s.referr [...]
-//# sourceMappingURL=index-BVs2fZoR.js.map
diff --git a/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-C3yUz-LS.js b/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-C3yUz-LS.js
new file mode 100644
index 00000000000..0923401c08d
--- /dev/null
+++ b/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-C3yUz-LS.js
@@ -0,0 +1,2 @@
+(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referr [...]
+//# sourceMappingURL=index-C3yUz-LS.js.map
diff --git a/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-BVs2fZoR.js.map b/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-C3yUz-LS.js.map
similarity index 68%
rename from viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-BVs2fZoR.js.map
rename to viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-C3yUz-LS.js.map
index f76bed6bed3..9e31b0c52dd 100644
--- a/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-BVs2fZoR.js.map
+++ b/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/assets/index-C3yUz-LS.js.map
@@ -1 +1 @@
-{"version":3,"file":"index-BVs2fZoR.js","sources":["../../../../../../frontend/node_modules/@vue/shared/dist/shared.esm-bundler.js","../../../../../../frontend/node_modules/@vue/reactivity/dist/reactivity.esm-bundler.js","../../../../../../frontend/node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js","../../../../../../frontend/node_modules/@vue/runtime-dom/dist/runtime-dom.esm-bundler.js","../../../../../../frontend/node_modules/vue-router/dist/devtools-EWN81iOl.mjs","../../ [...]
\ No newline at end of file
+{"version":3,"file":"index-C3yUz-LS.js","sources":["../../../../../../frontend/node_modules/@vue/shared/dist/shared.esm-bundler.js","../../../../../../frontend/node_modules/@vue/reactivity/dist/reactivity.esm-bundler.js","../../../../../../frontend/node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js","../../../../../../frontend/node_modules/@vue/runtime-dom/dist/runtime-dom.esm-bundler.js","../../../../../../frontend/node_modules/vue-router/dist/devtools-EWN81iOl.mjs","../../ [...]
\ No newline at end of file
diff --git a/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/index.html b/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/index.html
index bb305da0f58..547c3f7629b 100644
--- a/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/index.html
+++ b/viewers/webcomponents/sample-vue-petclinic/src/main/resources/static/vue/index.html
@@ -27,7 +27,7 @@
<link rel="stylesheet" href="/causeway-webcomponents/component-styles.css">
<link rel="stylesheet" href="/causeway-webcomponents/theme.css">
<link rel="stylesheet" href="/webjars/font-awesome/7.3.0/css/all.min.css">
- <script type="module" crossorigin src="/vue/assets/index-BVs2fZoR.js"></script>
+ <script type="module" crossorigin src="/vue/assets/index-C3yUz-LS.js"></script>
<link rel="stylesheet" crossorigin href="/vue/assets/index-CqcjgOE7.css">
</head>
<body>
diff --git a/viewers/webcomponents/sample-vue-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinic/PetClinicVuePlaywrightTest.java b/viewers/webcomponents/sample-vue-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinic/PetClinicVuePlaywrightTest.java
index c5db0fa455e..ecf87098072 100644
--- a/viewers/webcomponents/sample-vue-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinic/PetClinicVuePlaywrightTest.java
+++ b/viewers/webcomponents/sample-vue-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinic/PetClinicVuePlaywrightTest.java
@@ -222,6 +222,10 @@ void shellAndOwnerLayoutMatchTheWideAndNarrowReferenceInvariants() {
assertThat(page.locator("body").evaluate("element => getComputedStyle(element).fontFamily"))
.asString().contains("Inter");
assertThat(page.locator("cw-menubars").innerText()).containsSubsequence("Pet Owners", "Visits", "Account");
+ assertThat(page.locator("cw-menubars").innerText()).doesNotContain("System");
+ assertThat(page.locator("cw-menubar-secondary").count()).isZero();
+ assertThat(page.locator("cw-menubar-tertiary").evaluate(
+ "element => element._projection.menus.map(menu => menu.label).join(',')")).isEqualTo("Account");
assertThat(page.locator("footer").innerText()).contains("Powered by Apache Causeway", "Vue viewer");
assertThat(page.locator(".causeway-object-identity").isVisible()).isFalse();
assertThat(page.locator("[data-causeway-route-page]").evaluate("element => getComputedStyle(element).outlineStyle"))
@@ -332,6 +336,9 @@ void bothToolkitPoliciesPreserveAccessibleShellLandmarksAndMenus() {
assertThat(page.locator("footer").count()).isEqualTo(1);
page.locator("cw-menubars[data-menu-state='ready']").waitFor();
assertThat(page.locator("cw-menubars[data-menu-state='ready']").count()).isEqualTo(1);
+ assertThat(page.locator("cw-menubar-secondary").count()).isZero();
+ assertThat(page.locator("cw-menubar-tertiary").evaluate(
+ "element => element._projection.menus.map(menu => menu.label).join(',')")).isEqualTo("Account");
assertThat((Boolean) page.locator("cw-menubar-tertiary").evaluate("""
element => {
const actions = Object.values(element._projection?.actions ?? {});
diff --git a/viewers/webcomponents/sample-vue-petclinic/src/secured-playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredPlaywrightAcceptance.java b/viewers/webcomponents/sample-vue-petclinic/src/secured-playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredPlaywrightAcceptance.java
index 2ad6ff3fddc..d9f3e207518 100644
--- a/viewers/webcomponents/sample-vue-petclinic/src/secured-playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredPlaywrightAcceptance.java
+++ b/viewers/webcomponents/sample-vue-petclinic/src/secured-playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredPlaywrightAcceptance.java
@@ -24,8 +24,10 @@
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
+import com.microsoft.playwright.options.LoadState;
import com.microsoft.playwright.options.WaitUntilState;
import org.junit.jupiter.api.AfterAll;
@@ -97,7 +99,7 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
}
});
- final var deepLink = "/vue/object/petclinic.PetOwner/s_owner-mary";
+ final var deepLink = "/vue/object/petclinic.PetOwner/s_owner-mary?toolkit=native";
page.navigate(origin() + deepLink, new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
page.waitForURL("**/vue/login**");
assertThat(page.locator("cw-graphql-client").count()).isZero();
@@ -126,8 +128,9 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
assertThat(page.locator("[data-testid='vue-authentication-shell']").count()).isZero();
assertThat(page.locator("[data-causeway-authentication-logout]").getAttribute("hidden")).isNotNull();
page.waitForFunction("() => ['ready','partial-error'].includes(document.querySelector('cw-menubars')?.dataset.menuState)");
- page.waitForFunction("() => [...document.querySelectorAll('cw-menubar-primary, cw-menubar-secondary, cw-menubar-tertiary')].filter(element => !element.hidden).every(element => element.dataset.causewayMenubarPresentation?.startsWith('vaadin-') && element.querySelector('cw-menubar-control')?.dataset.widgetState === 'ready')");
- assertThat(menuBarRequests).hasSize(1);
+ page.waitForFunction("() => [...document.querySelectorAll('cw-menubar-primary, cw-menubar-secondary, cw-menubar-tertiary')].filter(element => !element.hidden).every(element => element.dataset.causewayMenubarPresentation === 'native' && !element.querySelector('cw-menubar-control'))");
+ assertThat(menuBarRequests).isEmpty();
+ assertThat(page.locator("cw-menubar-secondary").count()).isZero();
assertThat(page.locator("cw-menubar-tertiary").isVisible()).isTrue();
assertThat((Boolean) page.locator("cw-menubar-tertiary").evaluate("""
element => {
@@ -137,15 +140,48 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
['causeway.conf.ConfigurationMenu#configuration', 'Configuration'],
['causeway.security.LogoutMenu#logout', 'Sign out']
]);
- return [...expected].every(([identity, label]) => {
- const action = actions.find(candidate => `${candidate.serviceLogicalTypeName}#${candidate.actionId}` === identity);
- return action?.label === label && action.role === 'tertiary';
- });
+ const menuLabels = element._projection?.menus?.map(menu => menu.label) ?? [];
+ return menuLabels.length === 1 && menuLabels[0] === 'sven'
+ && [...expected].every(([identity, label]) => {
+ const action = actions.find(candidate => `${candidate.serviceLogicalTypeName}#${candidate.actionId}` === identity);
+ return action?.label === label
+ && action.role === 'tertiary'
+ && (identity !== 'causeway.security.LogoutMenu#logout' || action.appearance === 'sign-out');
+ });
}
"""))
.as(page.locator("cw-menubar-tertiary").evaluate("element => Object.values(element._projection?.actions ?? {}).map(action => `${action.serviceLogicalTypeName}#${action.actionId}:${action.label}:${action.role}`)").toString())
.isTrue();
assertThat(graphQlCsrfHeaders).isNotEmpty().allSatisfy(value -> assertThat(value).isNotBlank());
+ final var userDisclosure = page.locator("[data-causeway-menu-disclosure]")
+ .filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("sven")).first();
+ userDisclosure.click();
+ final var signOutControl = page.locator(
+ "[data-service-logical-type='causeway.security.LogoutMenu'][data-action-id='logout']");
+ assertThat(signOutControl.getAttribute("data-action-appearance")).isEqualTo("sign-out");
+ assertThat(signOutControl.evaluate("element => getComputedStyle(element).borderTopStyle")).isEqualTo("solid");
+ assertThat(page.locator("[data-service-logical-type='causeway.applib.UserMenu'][data-action-id='me']")
+ .getAttribute("data-action-appearance")).isNull();
+ page.keyboard().press("Escape");
+
+ userDisclosure.click();
+ page.locator("[data-service-logical-type='causeway.applib.UserMenu'][data-action-id='me']").click();
+ page.waitForURL("**/vue/object/causeway.applib.UserMemento/**");
+ page.waitForFunction("() => { const route = document.querySelector('[data-causeway-route-page]'); return ['ready','partial-error'].includes(route?.dataset.routeState) && route.textContent.includes('Identity'); }");
+ assertThat(page.locator("body").innerText()).contains("Identity", "Name", "sven");
+ assertThat(page.locator("[data-testid='action-result']").count()).isZero();
+ assertThat(page.locator("cw-action-results[data-causeway-shell-result]").getAttribute("hidden")).isNotNull();
+
+ page.locator("[data-causeway-menu-disclosure]")
+ .filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("sven")).first().click();
+ page.locator("[data-service-logical-type='causeway.conf.ConfigurationMenu'][data-action-id='configuration']").click();
+ page.waitForURL("**/vue/object/causeway.conf.ConfigurationViewmodel/**");
+ page.waitForFunction("() => { const route = document.querySelector('[data-causeway-route-page]'); return ['ready','partial-error'].includes(route?.dataset.routeState) && route.textContent.includes('Configuration'); }");
+ assertThat(page.locator("body").innerText()).contains("Configuration").doesNotContain("me result");
+ assertThat(page.locator("[data-testid='action-result']").count()).isZero();
+ page.navigate(origin() + deepLink, new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
+ waitForReadyObject(page);
+ page.waitForLoadState(LoadState.NETWORKIDLE);
final var graphQlCountBeforeLogoutPolicy = graphQlCsrfHeaders.size();
final var frameworkLogoutClaimed = (Boolean) page.locator("[data-testid='petclinic-vue-application-shell']")
@@ -193,7 +229,7 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
page.locator(".causeway-shell-brand").click();
page.waitForURL("**/vue/login**");
assertThat(page.locator("#username").isVisible()).isTrue();
- assertThat(menuBarRequests).hasSize(1);
+ assertThat(menuBarRequests).isEmpty();
browserFailures.removeIf(message -> message.contains("status of 401"));
page.locator("#username").fill(PetClinicSecmanDataConfiguration.USERNAME);
@@ -201,17 +237,17 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
page.locator("button[type='submit']").click();
page.waitForURL("**/vue**");
page.waitForFunction("() => ['ready','partial-error'].includes(document.querySelector('cw-menubars')?.dataset.menuState) && [...document.querySelectorAll('cw-menubar-primary, cw-menubar-secondary, cw-menubar-tertiary')].filter(element => !element.hidden).every(element => element.querySelector('cw-menubar-control')?.dataset.widgetState === 'ready')");
- assertThat(menuBarRequests).hasSize(2);
- final var accountMenu = page.locator("vaadin-menu-bar-button")
- .filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("Account")).first();
- accountMenu.click();
+ assertThat(menuBarRequests).hasSize(1);
+ final var userMenu = firstVisible(page.locator("vaadin-menu-bar-button")
+ .filter(new Locator.FilterOptions().setHasText("sven")));
+ userMenu.click();
final var signOutItem = page.locator("vaadin-menu-bar-item")
.filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("Sign out")).last();
signOutItem.waitFor();
signOutItem.click();
page.waitForURL("**/vue/login?logout=true");
assertThat(page.locator("[role='status']").textContent()).contains("signed out");
- assertThat(menuBarRequests).hasSize(2);
+ assertThat(menuBarRequests).hasSize(1);
page.goBack();
page.waitForURL("**/vue/login**");
@@ -221,6 +257,16 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
}
}
+ private static Locator firstVisible(final Locator candidates) {
+ for (var index = 0; index < candidates.count(); index++) {
+ var candidate = candidates.nth(index);
+ if (candidate.isVisible()) {
+ return candidate;
+ }
+ }
+ throw new AssertionError("No visible locator among " + candidates.count() + " candidates");
+ }
+
private void waitForReadyObject(final Page page) {
try {
page.waitForFunction("() => ['ready','partial-error'].includes(document.querySelector('[data-causeway-route-page]')?.dataset.routeState)");
diff --git a/viewers/webcomponents/vue-security-secman/README.adoc b/viewers/webcomponents/vue-security-secman/README.adoc
index b10b633b4c6..1ad841f86d9 100644
--- a/viewers/webcomponents/vue-security-secman/README.adoc
+++ b/viewers/webcomponents/vue-security-secman/README.adoc
@@ -100,7 +100,8 @@ Authentication, GraphQL, assets, malformed paths, unsafe methods, unrelated path
Spring migrates the session identifier after login.
The stable Vue shell retains a non-visual native POST logout form carrying the context's CSRF parameter and token.
-Its `menuActionLabel` policy presents exact `causeway.security.LogoutMenu#logout` as **Sign out** in the authorized tertiary semantic menu location, while the typed action policy claims only that identity and submits the form before GraphQL invocation.
+Its `menuLabel` policy presents the bounded authentication-context username as the utility-menu title, while `menuActionLabel` and `menuActionAppearance` present exact `causeway.security.LogoutMenu#logout` as distinctly outlined **Sign out** in the authorized tertiary semantic location.
+The typed action policy claims only that identity and submits the form before GraphQL invocation.
Other tertiary actions remain subject to ordinary menu layout and SecMan authorization, so applications must grant any regular-user or configuration roles needed to preserve controls such as **Me** and **Configuration**.
The shell renders no separate visible username or logout block.
Successful logout clears authentication, invalidates the session, applies configured cookie cleanup, and redirects to the signed-out login page.
diff --git a/viewers/webcomponents/vue/README.adoc b/viewers/webcomponents/vue/README.adoc
index 031ea7ff619..9a0909692de 100644
--- a/viewers/webcomponents/vue/README.adoc
+++ b/viewers/webcomponents/vue/README.adoc
@@ -125,11 +125,12 @@ Registrations are immutable after plugin creation and do not use type inheritanc
== Public exports
The package exports route codecs, route names and records, the viewer plugin factory, shell binding helpers, boundary validators, policy helpers, public TypeScript contracts, and the generic route components.
-Applications may replace pre-invocation action, menu-action label, navigation, home, result, and error policy through viewer options.
+Applications may replace pre-invocation action, menu-title, menu-action label, menu-action appearance, navigation, home, result, and error policy through viewer options.
The typed action policy receives canonical action-request detail, an idempotent single-claim capability, and immutable route context; a synchronous claim prevents validation and GraphQL invocation, while an unclaimed asynchronous decision resumes an ordinary action exactly once if the route remains current.
Claimed semantic events are not also handled by the default policy.
The default policy removes and blocks only the exact framework action `causeway.security.LogoutMenu#logout`.
-Applications with authentication can return a bounded label from `menuActionLabel` for that exact identity, retain it in its authoritative semantic menu location, claim its activation, and own the secure logout workflow.
+Applications with authentication can return a bounded current-user title from `menuLabel`, a bounded label from `menuActionLabel`, and a bounded appearance token from `menuActionAppearance` for exact identities while retaining the authoritative semantic menu location.
+The application still claims exact Logout activation and owns the secure workflow; the generic package discovers no authentication state and infers neither username nor Logout from display text.
Without both explicit presentation and invocation policy, framework Logout fails closed before GraphQL and similarly named application actions remain ordinary.
`applicationResourceBase` is independent of `basePath` and identifies the application-local deployment base used for typed `LocalResourcePathValue` results.
@@ -140,7 +141,7 @@ Hosts may claim result policy first to replace or further restrict this behavior
== Secured host composition
Authentication remains an application responsibility.
-A secured application can fetch its current session and CSRF context before mounting, bind a CSRF-decorating executor with `:executor.prop` on its stable `<cw-graphql-client>`, retain a non-visual native POST logout form, present **Sign out** in the exact framework Logout menu location through `menuActionLabel`, and claim activation through the typed action policy.
+A secured application can fetch its current session and CSRF context before mounting, bind a CSRF-decorating executor with `:executor.prop` on its stable `<cw-graphql-client>`, retain a non-visual native POST logout form, label its utility menu through `menuLabel`, present **Sign out** through `menuActionLabel` and `menuActionAppearance`, and claim exact framework Logout activation through the typed action policy.
The viewer neither fetches credentials nor CSRF evidence, creates endpoints, persists tokens, or infers authentication from routes or `LocalResourcePathValue` results.
A host that does not supply this context makes no authentication bootstrap request and retains ordinary executor behavior.
@@ -155,6 +156,8 @@ Malformed, unsafe, non-canonical, or oversized segments fail before an object co
The stable shell keeps its GraphQL provider and menus connected while canonical route keys dispose obsolete object contexts.
Object-context state remains authoritative for loading, ready, partial-error, and terminal-error presentation.
+For unclaimed results, the default bridge transfers ownership exactly once: it clears obsolete viewer-owned outlets, dismisses transient source result chrome, routes complete entity or view-model identity canonically, and replaces scalar, collection, void, or unsupported presentation.
+A claimed result remains wholly application-owned, and a result that becomes obsolete while policy awaits cannot clear or repopulate a newer route.
The application owns shell CSS and may copy or override the package reference theme through documented custom properties.
The semantic components continue to own object layout, menus, validation, interactions, results, collection previews, and toolkit selection.
diff --git a/viewers/webcomponents/vue/dist/causeway-vue.js b/viewers/webcomponents/vue/dist/causeway-vue.js
index ca471bc3d42..029231b7d98 100644
--- a/viewers/webcomponents/vue/dist/causeway-vue.js
+++ b/viewers/webcomponents/vue/dist/causeway-vue.js
@@ -1,58 +1,58 @@
-import { inject as de, onMounted as D, onBeforeUnmount as ne, defineComponent as O, ref as N, openBlock as R, createElementBlock as U, createElementVNode as L, toDisplayString as pe, computed as I, h as S, defineAsyncComponent as fe, watch as ge, createBlock as he, resolveDynamicComponent as ye, nextTick as ve } from "vue";
-import { useRoute as we } from "vue-router";
-const G = "cw-object-context[data-causeway-route-context]", me = "cw-interaction-controller[data-causeway-route-interactions]";
-function be(e, t) {
- const a = [...e.querySelectorAll(G)].filter((c) => !c.parentElement?.closest(G));
+import { inject as ye, onMounted as F, onBeforeUnmount as ce, defineComponent as j, ref as P, openBlock as I, createElementBlock as G, createElementVNode as x, toDisplayString as ve, computed as k, h as C, defineAsyncComponent as we, watch as me, createBlock as be, resolveDynamicComponent as Ee, nextTick as Ae } from "vue";
+import { useRoute as Te } from "vue-router";
+const V = "cw-object-context[data-causeway-route-context]", _e = "cw-interaction-controller[data-causeway-route-interactions]";
+function Se(e, t) {
+ const a = [...e.querySelectorAll(V)].filter((c) => !c.parentElement?.closest(V));
if (a.length === 0) return Object.freeze({ valid: !1, classification: "missing-context" });
if (a.length !== 1) return Object.freeze({ valid: !1, classification: "duplicate-context" });
const n = a[0];
- return n.getAttribute("logical-type") !== t.logicalTypeName || n.getAttribute("object-id") !== t.objectId ? Object.freeze({ valid: !1, classification: "identity" }) : [...n.querySelectorAll(me)].filter((c) => c.closest(G) === n).length !== 1 ? Object.freeze({ valid: !1, classification: "interactions" }) : Object.freeze({ valid: !0, context: n });
+ return n.getAttribute("logical-type") !== t.logicalTypeName || n.getAttribute("object-id") !== t.objectId ? Object.freeze({ valid: !1, classification: "identity" }) : [...n.querySelectorAll(_e)].filter((c) => c.closest(V) === n).length !== 1 ? Object.freeze({ valid: !1, classification: "interactions" }) : Object.freeze({ valid: !0, context: n });
}
-function x(e, t) {
+function N(e, t) {
const a = e.querySelectorAll(t);
return a.length === 1 ? a[0] : null;
}
-function oe(e) {
- const t = x(e, "cw-graphql-client[data-causeway-shell-client]"), a = x(e, "[data-causeway-router-view]"), n = x(e, "[data-causeway-route-loading]"), o = x(e, "[data-causeway-route-announcement]"), c = x(e, "cw-action-results[data-causeway-shell-result]");
+function ie(e) {
+ const t = N(e, "cw-graphql-client[data-causeway-shell-client]"), a = N(e, "[data-causeway-router-view]"), n = N(e, "[data-causeway-route-loading]"), o = N(e, "[data-causeway-route-announcement]"), c = N(e, "cw-action-results[data-causeway-shell-result]");
if (!t || !a || !n || !o || !c || !t.contains(a) || !t.contains(c))
throw new Error("The authored Vue application shell is invalid.");
return Object.freeze({ shell: e, client: t, route: a, loading: n, announcement: o, result: c });
}
-const re = /* @__PURE__ */ Symbol("causeway-vue-viewer"), K = Object.freeze({
+const se = /* @__PURE__ */ Symbol("causeway-vue-viewer"), H = Object.freeze({
serviceLogicalTypeName: "causeway.security.LogoutMenu",
actionId: "logout"
});
-function V(e) {
- return e?.serviceLogicalTypeName === K.serviceLogicalTypeName && e?.actionId === K.actionId;
+function $(e) {
+ return e?.serviceLogicalTypeName === H.serviceLogicalTypeName && e?.actionId === H.actionId;
}
-function Y(e) {
+function Q(e) {
let t = 0;
for (const a of e.querySelectorAll("[data-causeway-service-action]"))
- V({
+ $({
serviceLogicalTypeName: a.getAttribute("data-service-logical-type") ?? void 0,
actionId: a.getAttribute("data-action-id") ?? ""
}) && ((a.closest("[data-causeway-service-action-region]") ?? a).remove(), t += 1);
return t;
}
-class Ee extends Error {
+class Ce extends Error {
code;
constructor(t, a) {
super(a), this.name = "LocalResourceNavigationError", this.code = t;
}
}
-function Te(e, t = {}) {
+function Le(e, t = {}) {
const a = t.location ?? window.location, n = typeof e?.path == "string" ? e.path.trim() : "", o = e?.openUrlStrategy;
if (!n || !["SAME_WINDOW", "NEW_WINDOW"].includes(o ?? ""))
- throw P("LOCAL_RESOURCE_INVALID", "The local-resource result is incomplete.");
+ throw U("LOCAL_RESOURCE_INVALID", "The local-resource result is incomplete.");
if (/^[a-z][a-z\d+.-]*:/i.test(n) || n.startsWith("//") || n.includes("\\") || /[\u0000-\u001f\u007f]/.test(n))
- throw P("LOCAL_RESOURCE_TARGET_UNSAFE", "The local-resource target is not an application-local path.");
- const c = new URL(a.href), u = Ae(t.applicationBase ?? "/", c).pathname.replace(/\/+$/, "") || "/", l = n.startsWith("/") ? n : `/${n}`, g = u !== "/" && (l === u || l.startsWith(`${u}/`) || l.startsWith(`${u}?`) || l.startsWith(`${u}#`)) ? l : `${u === "/" ? "" : u}/${l.replace(/^\/+/, "")}`, h = new URL(g, c.origin);
+ throw U("LOCAL_RESOURCE_TARGET_UNSAFE", "The local-resource target is not an application-local path.");
+ const c = new URL(a.href), u = Oe(t.applicationBase ?? "/", c).pathname.replace(/\/+$/, "") || "/", d = n.startsWith("/") ? n : `/${n}`, g = u !== "/" && (d === u || d.startsWith(`${u}/`) || d.startsWith(`${u}?`) || d.startsWith(`${u}#`)) ? d : `${u === "/" ? "" : u}/${d.replace(/^\/+/, "")}`, h = new URL(g, c.origin);
if (h.origin !== c.origin || h.username || h.password || u !== "/" && h.pathname !== u && !h.pathname.startsWith(`${u}/`))
- throw P("LOCAL_RESOURCE_TARGET_UNSAFE", "The local-resource target escapes the configured application boundary.");
+ throw U("LOCAL_RESOURCE_TARGET_UNSAFE", "The local-resource target escapes the configured application boundary.");
return Object.freeze({ url: h, openUrlStrategy: o });
}
-function _e(e, t = {}) {
- const a = t.location ?? window.location, n = Te(e, { location: a, applicationBase: t.applicationBase });
+function xe(e, t = {}) {
+ const a = t.location ?? window.location, n = Le(e, { location: a, applicationBase: t.applicationBase });
if (n.openUrlStrategy === "SAME_WINDOW") {
a.assign(n.url.href);
return;
@@ -60,23 +60,23 @@ function _e(e, t = {}) {
const c = (t.open ?? window.open.bind(window))(n.url.href, "_blank", "noopener,noreferrer");
c && (c.opener = null);
}
-function Ae(e, t) {
+function Oe(e, t) {
const a = String(e).trim() || "/";
if (a.startsWith("//") || /[\u0000-\u001f\u007f]/.test(a))
- throw P("LOCAL_RESOURCE_BASE_INVALID", "The application-local resource base is invalid.");
+ throw U("LOCAL_RESOURCE_BASE_INVALID", "The application-local resource base is invalid.");
const n = new URL(a.endsWith("/") ? a : `${a}/`, t.origin);
if (n.origin !== t.origin || n.username || n.password)
- throw P("LOCAL_RESOURCE_BASE_INVALID", "The application-local resource base must be same-origin.");
+ throw U("LOCAL_RESOURCE_BASE_INVALID", "The application-local resource base must be same-origin.");
return n;
}
-function P(e, t) {
- return new Ee(e, t);
+function U(e, t) {
+ return new Ce(e, t);
}
-const X = 4096, ie = 4096, Se = "The requested application route is invalid.";
-function _() {
- return new Error(Se);
+const J = 4096, ue = 4096, je = "The requested application route is invalid.";
+function S() {
+ return new Error(je);
}
-function Ce(e) {
+function Ne(e) {
for (let t = 0; t < e.length; t += 1) {
const a = e.charCodeAt(t);
if (a >= 55296 && a <= 56319) {
@@ -88,59 +88,59 @@ function Ce(e) {
}
return !1;
}
-function $(e) {
+function W(e) {
const t = String(e ?? "");
- if (!t || t.length > X || t === "." || t === ".." || /[\\/\u0000-\u001f\u007f-\u009f]/u.test(t) || Ce(t) || new TextEncoder().encode(t).length > X) throw _();
+ if (!t || t.length > J || t === "." || t === ".." || /[\\/\u0000-\u001f\u007f-\u009f]/u.test(t) || Ne(t) || new TextEncoder().encode(t).length > J) throw S();
let a;
try {
a = encodeURIComponent(t).replace(/[!'()*]/g, (n) => `%${n.charCodeAt(0).toString(16).toUpperCase()}`);
} catch {
- throw _();
+ throw S();
}
- if (a.length > ie) throw _();
+ if (a.length > ue) throw S();
return a;
}
-function Z(e) {
- if (!e || e.length > ie || !/^(?:[^%]|%[0-9A-F]{2})+$/u.test(e))
- throw _();
+function ee(e) {
+ if (!e || e.length > ue || !/^(?:[^%]|%[0-9A-F]{2})+$/u.test(e))
+ throw S();
let t;
try {
t = decodeURIComponent(e);
} catch {
- throw _();
+ throw S();
}
- if ($(t) !== e) throw _();
+ if (W(t) !== e) throw S();
return t;
}
-function k(e) {
+function q(e) {
let t = String(e ?? "").trim();
if (!t.startsWith("/") || t.startsWith("//") || /[?#\\\u0000-\u001f]/u.test(t))
throw new Error("The Vue viewer base path is invalid.");
for (; t.length > 1 && t.endsWith("/"); ) t = t.slice(0, -1);
return t;
}
-function Le(e, t) {
- const a = k(e), n = `/object/${$(t?.logicalTypeName)}/${$(t?.id ?? t?.objectId)}`;
+function Re(e, t) {
+ const a = q(e), n = `/object/${W(t?.logicalTypeName)}/${W(t?.id ?? t?.objectId)}`;
return a === "/" ? n : `${a}${n}`;
}
-function M(e) {
- return Le("/", e);
+function K(e) {
+ return Re("/", e);
}
-function ce(e, t = "/") {
- const a = String(e ?? ""), n = k(t), o = n === "/" ? "/object/" : `${n}/object/`;
- if (!a.startsWith(o)) throw _();
- const d = a.slice(o.length).split("/");
- if (d.length !== 2) throw _();
+function le(e, t = "/") {
+ const a = String(e ?? ""), n = q(t), o = n === "/" ? "/object/" : `${n}/object/`;
+ if (!a.startsWith(o)) throw S();
+ const p = a.slice(o.length).split("/");
+ if (p.length !== 2) throw S();
return Object.freeze({
- logicalTypeName: Z(d[0]),
- objectId: Z(d[1])
+ logicalTypeName: ee(p[0]),
+ objectId: ee(p[1])
});
}
-function Oe(e) {
- return M(e);
+function Pe(e) {
+ return K(e);
}
-const H = "causeway-navigation-request", B = "causeway-action-request", Q = "causeway-action-result", J = "causeway-object-context-state-change", ee = "causeway-menu-bars-state-change";
-function W() {
+const te = "causeway-navigation-request", B = "causeway-action-request", ae = "causeway-action-result", ne = "causeway-object-context-state-change", oe = "causeway-menu-bars-state-change";
+function z() {
let e = !1;
return {
get claimed() {
@@ -151,7 +151,7 @@ function W() {
}
};
}
-function E(e) {
+function m(e) {
return Object.freeze({
router: e.router,
basePath: e.basePath,
@@ -159,145 +159,178 @@ function E(e) {
routeGeneration: e.state.routeGeneration
});
}
-function xe(e) {
+function Ie(e) {
if (e?.kind !== "object" || !e.value || typeof e.value != "object") return null;
const t = e.value._meta, a = t?.logicalTypeName, n = t?.id;
return typeof a != "string" || typeof n != "string" || !a || !n ? null : Object.freeze({ logicalTypeName: a, id: n, title: String(t?.title ?? n) });
}
-function je(e) {
+function Ue(e) {
return [...e.querySelector("[data-causeway-router-view]")?.querySelectorAll("cw-action-results[data-causeway-page-result]") ?? []].filter((a) => a.isConnected);
}
-function Ne(e) {
+function ke(e) {
const t = e.querySelectorAll("cw-action-results[data-causeway-shell-result]");
return t.length === 1 ? t[0] : null;
}
-function z(e) {
- const t = je(e);
+function D(e) {
+ const t = Ue(e);
if (t.length > 1) throw new Error("The active Vue route has duplicate action-result outlets.");
if (t.length === 1) return t[0];
- const a = Ne(e);
+ const a = ke(e);
if (!a?.isConnected) throw new Error("The authored Vue shell result outlet is unavailable.");
return a;
}
-function te(e, ...t) {
+function M(e, ...t) {
const a = e;
a.replacePresentation ? a.replacePresentation(...t) : e.replaceChildren(...t), e.hidden = t.length === 0;
}
-function se(e, t) {
+function Ge(e) {
+ const t = e.querySelectorAll(
+ "cw-action-results[data-causeway-page-result], cw-action-results[data-causeway-shell-result]"
+ );
+ for (const a of t)
+ a.isConnected && M(a);
+}
+function de(e, t) {
const a = t.result, n = document.createElement("h2");
if (n.textContent = t.actionId ? `${t.actionId} result` : "Action result", a?.kind === "collection") {
const c = document.createElement("cw-standalone-collection");
- c.setAttribute("data-testid", "causeway-standalone-action-result"), c.setAttribute("named", String(t.resultPresentation?.named ?? n.textContent)), c.result = a, te(e, c);
+ c.setAttribute("data-testid", "causeway-standalone-action-result"), c.setAttribute("named", String(t.resultPresentation?.named ?? n.textContent)), c.result = a, M(e, c);
return;
}
const o = document.createElement("output");
- o.textContent = a?.kind === "scalar" ? String(a.value ?? "") : a?.kind === "void" ? "Completed" : "This result cannot be presented.", te(e, n, o);
+ o.textContent = a?.kind === "scalar" ? String(a.value ?? "") : a?.kind === "void" ? "Completed" : "This result cannot be presented.", M(e, n, o);
}
-async function Re(e, t) {
+async function qe(e, t) {
const a = e.state.shell, o = a?.querySelector("[data-causeway-router-view]")?.querySelector("cw-object-context[data-causeway-route-context]"), c = o?.context;
if (!o || !c?.refresh) return;
- const d = e.state.routeGeneration, u = (v) => {
- if (d !== e.state.routeGeneration) return l();
- const g = v.detail?.state;
+ const p = e.state.routeGeneration, u = (b) => {
+ if (p !== e.state.routeGeneration) return d();
+ const g = b.detail?.state;
if (g?.status !== "terminal-error") {
- (g?.status === "ready" || g?.status === "partial-error") && l();
+ (g?.status === "ready" || g?.status === "partial-error") && d();
return;
}
- l(), (g.errors?.[0]?.extensions?.classification ?? g.errors?.[0]?.extensions?.code ?? g.error?.code) === "NOT_FOUND" && e.router.replace("/");
- }, l = () => o.removeEventListener(J, u);
- o.addEventListener(J, u), c.refresh(), se(z(a), t);
+ d(), (g.errors?.[0]?.extensions?.classification ?? g.errors?.[0]?.extensions?.code ?? g.error?.code) === "NOT_FOUND" && e.router.replace("/");
+ }, d = () => o.removeEventListener(ne, u);
+ o.addEventListener(ne, u), c.refresh(), de(D(a), t);
}
-async function ae(e, t) {
- const a = W();
- await e.policies.navigate?.(t, a, E(e)) === !0 && a.claim(), a.claimed || await e.router.push(M(t));
+async function re(e, t) {
+ const a = z();
+ await e.policies.navigate?.(t, a, m(e)) === !0 && a.claim(), a.claimed || await e.router.push(K(t));
}
-function ue(e, t) {
- const a = /* @__PURE__ */ new WeakMap(), n = /* @__PURE__ */ new WeakSet(), o = t.querySelector("cw-menubars"), c = (r) => {
+function pe(e, t) {
+ const a = /* @__PURE__ */ new WeakMap(), n = /* @__PURE__ */ new WeakSet(), o = t.querySelector("cw-menubars"), c = (i) => {
+ try {
+ const r = e.policies.menuLabel?.(i, m(e));
+ return typeof r == "string" && r.trim() ? r : void 0;
+ } catch (r) {
+ e.policies.error?.(r, m(e));
+ return;
+ }
+ }, p = (i) => {
try {
- const i = e.policies.menuActionLabel?.(r, E(e));
- return typeof i == "string" && i.trim() ? i : void 0;
- } catch (i) {
- e.policies.error?.(i, E(e));
+ const r = e.policies.menuActionLabel?.(i, m(e));
+ return typeof r == "string" && r.trim() ? r : void 0;
+ } catch (r) {
+ e.policies.error?.(r, m(e));
return;
}
- }, d = () => !!c({
+ }, u = (i) => {
+ try {
+ const r = e.policies.menuActionAppearance?.(i, m(e));
+ return typeof r == "string" && r.trim() ? r : void 0;
+ } catch (r) {
+ e.policies.error?.(r, m(e));
+ return;
+ }
+ }, d = () => !!p({
serviceLogicalTypeName: "causeway.security.LogoutMenu",
actionId: "logout"
- }), u = (r) => V(r) && !c(r);
- o && (o.excludeAction = u, o.actionLabel = c);
- let l = null, v = !0;
- const g = () => {
- const r = t.querySelector("[data-causeway-route-announcement]");
- r && (r.textContent = "Logout requires a host authentication integration."), t.dataset.causewayLogoutUnavailable = "true";
- }, h = (r, i) => {
- if (!v || i !== e.state.routeGeneration || !(r.target instanceof EventTarget)) return;
+ }), b = (i) => $(i) && !p(i);
+ o && (o.excludeAction = b, o.menuLabel = c, o.actionLabel = p, o.actionAppearance = u);
+ let g = null, h = !0;
+ const E = () => {
+ const i = t.querySelector("[data-causeway-route-announcement]");
+ i && (i.textContent = "Logout requires a host authentication integration."), t.dataset.causewayLogoutUnavailable = "true";
+ }, _ = (i, r) => {
+ if (!h || r !== e.state.routeGeneration || !(i.target instanceof EventTarget)) return;
const s = new CustomEvent(B, {
bubbles: !0,
composed: !0,
cancelable: !0,
- detail: r.detail
+ detail: i.detail
});
- n.add(s), r.target.dispatchEvent(s);
- }, m = (r) => {
- const i = r, s = i.detail;
+ n.add(s), i.target.dispatchEvent(s);
+ }, A = (i) => {
+ const r = i, s = r.detail;
try {
- const y = z(t);
- s?.context && typeof s.context == "object" ? a.set(s.context, y) : l = y;
- } catch (y) {
- e.policies.error?.(y, E(e));
+ const w = D(t);
+ s?.context && typeof s.context == "object" ? a.set(s.context, w) : g = w;
+ } catch (w) {
+ e.policies.error?.(w, m(e));
}
- if (n.has(i)) return;
- const p = V(s), A = e.policies.action;
- if (!A) {
- p && (i.preventDefault(), g());
+ if (n.has(r)) return;
+ const y = $(s), T = e.policies.action;
+ if (!T) {
+ y && (r.preventDefault(), E());
return;
}
- const f = W(), T = e.state.routeGeneration;
+ const v = z(), X = e.state.routeGeneration;
try {
- const y = A(s, f, E(e));
- if (y && typeof y.then == "function") {
- i.preventDefault(), Promise.resolve(y).then((q) => {
- q === !0 && f.claim(), !f.claimed && !p ? h(i, T) : f.claimed || g();
- }).catch((q) => e.policies.error?.(q, E(e)));
+ const w = T(s, v, m(e));
+ if (w && typeof w.then == "function") {
+ r.preventDefault(), Promise.resolve(w).then((O) => {
+ O === !0 && v.claim(), !v.claimed && !y ? _(r, X) : v.claimed || E();
+ }).catch((O) => e.policies.error?.(O, m(e)));
return;
}
- y === !0 && f.claim(), (f.claimed || p) && i.preventDefault(), p && !f.claimed && g();
- } catch (y) {
- i.preventDefault(), e.policies.error?.(y, E(e));
+ w === !0 && v.claim(), (v.claimed || y) && r.preventDefault(), y && !v.claimed && E();
+ } catch (w) {
+ r.preventDefault(), e.policies.error?.(w, m(e));
}
- }, b = (r) => {
- const i = r, s = i.detail?.target;
- !s?.logicalTypeName || !(s.id ?? s.objectId) || (i.preventDefault(), ae(e, s).catch((p) => e.policies.error?.(p, E(e))));
- }, w = (r) => {
- const i = r, s = i.detail ?? {};
+ }, L = (i) => {
+ const r = i, s = r.detail?.target;
+ !s?.logicalTypeName || !(s.id ?? s.objectId) || (r.preventDefault(), re(e, s).catch((y) => e.policies.error?.(y, m(e))));
+ }, l = (i) => {
+ const r = i, s = r.detail ?? {}, y = e.state.routeGeneration, T = r.target;
(async () => {
- const p = W();
- if (await e.policies.result?.(s, p, E(e)) === !0 && p.claim(), p.claimed) return;
- if (s.result?.kind === "local-resource") {
- _e(s.result.value, { applicationBase: e.applicationResourceBase }), i.target?.dismissResult?.();
+ const v = z();
+ if (await e.policies.result?.(s, v, m(e)) === !0 && v.claim(), v.claimed || !h || y !== e.state.routeGeneration) return;
+ const w = () => {
+ try {
+ T?.dismissResult?.();
+ } catch (he) {
+ e.policies.error?.(he, m(e));
+ }
+ };
+ if (Ge(t), s.result?.kind === "local-resource") {
+ xe(s.result.value, { applicationBase: e.applicationResourceBase }), w();
return;
}
- const f = xe(s.result);
- if (f) return ae(e, f);
- const T = s.context && a.get(s.context) || l || z(t);
- s.result?.kind === "void" ? await Re(e, s) : se(T, s), i.target?.dismissResult?.();
- })().catch((p) => e.policies.error?.(p, E(e)));
- }, C = () => queueMicrotask(() => {
- v && !d() && Y(t);
+ const O = Ie(s.result);
+ if (O) {
+ w(), await re(e, O);
+ return;
+ }
+ const Z = s.context && a.get(s.context) || g, ge = Z?.isConnected ? Z : D(t);
+ s.context && a.delete(s.context), g = null, s.result?.kind === "void" ? await qe(e, s) : de(ge, s), w();
+ })().catch((v) => e.policies.error?.(v, m(e)));
+ }, f = () => queueMicrotask(() => {
+ h && !d() && Q(t);
});
- return d() || Y(t), t.addEventListener(B, m, { capture: !0 }), t.addEventListener(H, b), t.addEventListener(Q, w), t.addEventListener(ee, C), () => {
- v = !1, o?.excludeAction === u && (o.excludeAction = void 0), o?.actionLabel === c && (o.actionLabel = void 0), t.removeEventListener(B, m, { capture: !0 }), t.removeEventListener(H, b), t.removeEventListener(Q, w), t.removeEventListener(ee, C);
+ return d() || Q(t), t.addEventListener(B, A, { capture: !0 }), t.addEventListener(te, L), t.addEventListener(ae, l), t.addEventListener(oe, f), () => {
+ h = !1, o?.excludeAction === b && (o.excludeAction = void 0), o?.menuLabel === c && (o.menuLabel = void 0), o?.actionLabel === p && (o.actionLabel = void 0), o?.actionAppearance === u && (o.actionAppearance = void 0), t.removeEventListener(B, A, { capture: !0 }), t.removeEventListener(te, L), t.removeEventListener(ae, l), t.removeEventListener(oe, f);
};
}
-const Pe = /^[A-Za-z_][A-Za-z0-9_$-]*(?:\.[A-Za-z_][A-Za-z0-9_$-]*)*$/u;
-function Ie(e) {
+const Ve = /^[A-Za-z_][A-Za-z0-9_$-]*(?:\.[A-Za-z_][A-Za-z0-9_$-]*)*$/u;
+function Be(e) {
return e ? e instanceof Map ? [...e.entries()] : Array.isArray(e) ? e : Object.entries(e) : [];
}
-function Ue(e) {
+function $e(e) {
const t = /* @__PURE__ */ new Map();
- for (const [a, n] of Ie(e)) {
+ for (const [a, n] of Be(e)) {
const o = String(a ?? "").trim();
- if (!Pe.test(o)) throw new Error("A Vue page registration has an invalid logical type.");
+ if (!Ve.test(o)) throw new Error("A Vue page registration has an invalid logical type.");
if (!n || typeof n != "object" && typeof n != "function")
throw new Error(`The Vue page registration for ${o} is unsupported.`);
if (t.has(o)) throw new Error(`The Vue page registration for ${o} is duplicated.`);
@@ -305,10 +338,10 @@ function Ue(e) {
}
return t;
}
-function ke(e) {
+function We(e) {
return typeof e == "function";
}
-function Xe(e) {
+function tt(e) {
if (!e?.router) throw new Error("The Vue viewer requires the application router.");
const t = String(e.endpoint ?? "").trim();
if (!t) throw new Error("The Vue viewer GraphQL endpoint is required.");
@@ -317,39 +350,39 @@ function Xe(e) {
return n = Object.freeze({
plugin: {
install(c) {
- c.provide(re, n);
+ c.provide(se, n);
}
},
router: e.router,
endpoint: t,
- basePath: k(e.basePath ?? (e.router.options.history.base || "/")),
- applicationResourceBase: k(e.applicationResourceBase ?? "/"),
- pages: Ue(e.pages),
+ basePath: q(e.basePath ?? (e.router.options.history.base || "/")),
+ applicationResourceBase: q(e.applicationResourceBase ?? "/"),
+ pages: $e(e.pages),
policies: Object.freeze({ ...e.policies }),
developmentDiagnostics: e.developmentDiagnostics ?? !0,
state: a
}), n;
}
-function F() {
- const e = de(re);
+function Y() {
+ const e = ye(se);
if (!e) throw new Error("The Causeway Vue viewer plugin is not installed.");
return e;
}
-function Ze(e) {
- const t = F();
+function at(e) {
+ const t = Y();
let a = null;
- return D(() => {
+ return F(() => {
const n = e.value;
if (!n) throw new Error("The authored Vue application shell is missing.");
- oe(n), t.state.shell = n, a = ue(t, n);
- }), ne(() => {
+ ie(n), t.state.shell = n, a = pe(t, n);
+ }), ce(() => {
a?.(), a = null, t.state.shell === e.value && (t.state.shell = null);
}), e;
}
-function He(e, t) {
- const a = oe(t);
+function nt(e, t) {
+ const a = ie(t);
e.state.shell = t;
- const n = ue(e, t);
+ const n = pe(e, t);
return {
landmarks: a,
dispose() {
@@ -357,67 +390,67 @@ function He(e, t) {
}
};
}
-const qe = ["data-route-state"], Ge = /* @__PURE__ */ O({
+const ze = ["data-route-state"], De = /* @__PURE__ */ j({
__name: "HomePage",
setup(e) {
- const t = F(), a = N(null), n = N("loading"), o = N("Loading the application home page…");
- function c(d) {
+ const t = Y(), a = P(null), n = P("loading"), o = P("Loading the application home page…");
+ function c(p) {
let u = null;
- return d.dispatchEvent(new CustomEvent("causeway-graphql-client-request", {
+ return p.dispatchEvent(new CustomEvent("causeway-graphql-client-request", {
bubbles: !0,
composed: !0,
- detail: { provide(l) {
- u ??= l;
+ detail: { provide(d) {
+ u ??= d;
} }
})), u;
}
- return D(async () => {
- const d = ++t.state.routeGeneration, u = a.value ? c(a.value) : null;
+ return F(async () => {
+ const p = ++t.state.routeGeneration, u = a.value ? c(a.value) : null;
if (!u) {
n.value = "unsupported", o.value = "Choose an application action to begin.";
return;
}
try {
- const l = await u.describeApplicationEntry();
- if (d !== t.state.routeGeneration) return;
- if (!l?.supported) {
+ const d = await u.describeApplicationEntry();
+ if (p !== t.state.routeGeneration) return;
+ if (!d?.supported) {
n.value = "unsupported", o.value = "Choose an application action to begin.";
return;
}
- const v = await u.readApplicationEntry({ description: l });
- if (d !== t.state.routeGeneration) return;
- const g = v?.data?.home, h = g?.object?._meta, m = h?.logicalTypeName ?? g?.logicalTypeName, b = h?.id, w = { claimed: !1, claim() {
+ const b = await u.readApplicationEntry({ description: d });
+ if (p !== t.state.routeGeneration) return;
+ const g = b?.data?.home, h = g?.object?._meta, E = h?.logicalTypeName ?? g?.logicalTypeName, _ = h?.id, A = { claimed: !1, claim() {
return this.claimed ? !1 : (this.claimed = !0, !0);
- } }, C = { router: t.router, basePath: t.basePath, shell: t.state.shell, routeGeneration: d };
- if (await t.policies.home?.(v?.data, w, C) === !0 && w.claim(), d !== t.state.routeGeneration || w.claimed) return;
- if (g?.kind === "OBJECT" && m && b) {
- await t.router.replace(M({ logicalTypeName: m, id: b }));
+ } }, L = { router: t.router, basePath: t.basePath, shell: t.state.shell, routeGeneration: p };
+ if (await t.policies.home?.(b?.data, A, L) === !0 && A.claim(), p !== t.state.routeGeneration || A.claimed) return;
+ if (g?.kind === "OBJECT" && E && _) {
+ await t.router.replace(K({ logicalTypeName: E, id: _ }));
return;
}
- n.value = v?.errors?.length ? "partial-error" : "ready", o.value = "Choose an application action to begin.";
- } catch (l) {
- if (d !== t.state.routeGeneration) return;
- t.policies.error?.(l, { router: t.router, basePath: t.basePath, shell: t.state.shell, routeGeneration: d }), n.value = "partial-error", o.value = "The home page is unavailable; application menus remain available.";
+ n.value = b?.errors?.length ? "partial-error" : "ready", o.value = "Choose an application action to begin.";
+ } catch (d) {
+ if (p !== t.state.routeGeneration) return;
+ t.policies.error?.(d, { router: t.router, basePath: t.basePath, shell: t.state.shell, routeGeneration: p }), n.value = "partial-error", o.value = "The home page is unavailable; application menus remain available.";
}
- }), (d, u) => (R(), U("section", {
+ }), (p, u) => (I(), G("section", {
ref_key: "page",
ref: a,
class: "causeway-vue-route-page causeway-vue-status",
"data-route-state": n.value,
tabindex: "-1"
}, [
- u[0] || (u[0] = L("h1", null, "Welcome", -1)),
- L("p", null, pe(o.value), 1)
- ], 8, qe));
+ u[0] || (u[0] = x("h1", null, "Welcome", -1)),
+ x("p", null, ve(o.value), 1)
+ ], 8, ze));
}
-}), Be = {
+}), Me = {
class: "causeway-vue-route-page causeway-vue-route-object",
"data-causeway-route-page": "",
"data-page-kind": "generic",
"data-route-state": "loading",
tabindex: "-1",
"aria-label": "Object page"
-}, Ve = ["logical-type", "object-id"], $e = /* @__PURE__ */ O({
+}, Fe = ["logical-type", "object-id"], Ke = /* @__PURE__ */ j({
__name: "GenericObjectPage",
props: {
logicalTypeName: {},
@@ -425,226 +458,226 @@ const qe = ["data-route-state"], Ge = /* @__PURE__ */ O({
routeKey: {}
},
setup(e) {
- return (t, a) => (R(), U("section", Be, [
- L("cw-object-context", {
+ return (t, a) => (I(), G("section", Me, [
+ x("cw-object-context", {
"data-causeway-route-context": "",
"logical-type": e.logicalTypeName,
"object-id": e.objectId
}, [...a[0] || (a[0] = [
- L("cw-object", { editable: "" }, null, -1),
- L("cw-interaction-controller", { "data-causeway-route-interactions": "" }, null, -1)
- ])], 8, Ve)
+ x("cw-object", { editable: "" }, null, -1),
+ x("cw-interaction-controller", { "data-causeway-route-interactions": "" }, null, -1)
+ ])], 8, Fe)
]));
}
-}), We = ["data-route-key"], ze = {
+}), Ye = ["data-route-key"], Xe = {
key: 0,
class: "causeway-vue-route-page causeway-vue-status causeway-vue-status-danger",
"data-route-state": "terminal-error",
tabindex: "-1",
role: "alert"
-}, De = /* @__PURE__ */ O({
+}, Ze = /* @__PURE__ */ j({
__name: "ObjectRoutePage",
setup(e) {
- const t = we(), a = F(), n = N(null), o = N(null), c = I(() => ce(t.path, "/")), d = O(() => () => S("section", {
+ const t = Te(), a = Y(), n = P(null), o = P(null), c = k(() => le(t.path, "/")), p = j(() => () => C("section", {
class: "causeway-vue-route-page causeway-vue-status",
"data-route-state": "loading",
tabindex: -1,
role: "status"
- }, [S("h1", "Loading page…")])), u = O(() => () => S("section", {
+ }, [C("h1", "Loading page…")])), u = j(() => () => C("section", {
class: "causeway-vue-route-page causeway-vue-status causeway-vue-status-danger",
"data-route-state": "terminal-error",
tabindex: -1,
role: "alert"
- }, [S("h1", "Page unavailable"), S("p", "The application page could not be loaded.")])), l = I(() => Oe(c.value)), v = I(() => a.pages.get(c.value.logicalTypeName)), g = I(() => {
- const r = v.value;
- return r ? ke(r) ? fe({
+ }, [C("h1", "Page unavailable"), C("p", "The application page could not be loaded.")])), d = k(() => Pe(c.value)), b = k(() => a.pages.get(c.value.logicalTypeName)), g = k(() => {
+ const l = b.value;
+ return l ? We(l) ? we({
loader: async () => {
- const i = await r();
- return "default" in i ? i.default : i;
+ const f = await l();
+ return "default" in f ? f.default : f;
},
delay: 0,
timeout: 3e4,
- loadingComponent: d,
+ loadingComponent: p,
errorComponent: u,
- onError(i, s, p) {
- a.policies.error?.(i, {
+ onError(f, i, r) {
+ a.policies.error?.(f, {
router: a.router,
basePath: a.basePath,
shell: a.state.shell,
routeGeneration: a.state.routeGeneration
- }), p();
+ }), r();
}
- }) : r : $e;
+ }) : l : Ke;
});
function h() {
- const r = a.state.shell;
+ const l = a.state.shell;
return {
- route: r?.querySelector("[data-causeway-router-view]") ?? null,
- loading: r?.querySelector("[data-causeway-route-loading]") ?? null,
- announcement: r?.querySelector("[data-causeway-route-announcement]") ?? null
+ route: l?.querySelector("[data-causeway-router-view]") ?? null,
+ loading: l?.querySelector("[data-causeway-route-loading]") ?? null,
+ announcement: l?.querySelector("[data-causeway-route-announcement]") ?? null
};
}
- function m(r, i) {
- const s = h().announcement;
- s && (s.textContent = "", requestAnimationFrame(() => {
- i === a.state.routeGeneration && s.isConnected && (s.textContent = r);
+ function E(l, f) {
+ const i = h().announcement;
+ i && (i.textContent = "", requestAnimationFrame(() => {
+ f === a.state.routeGeneration && i.isConnected && (i.textContent = l);
}));
}
- function b(r) {
- const i = h();
- i.route?.setAttribute("aria-busy", String(r)), i.loading && (i.loading.hidden = !r);
+ function _(l) {
+ const f = h();
+ f.route?.setAttribute("aria-busy", String(l)), f.loading && (f.loading.hidden = !l);
}
- async function w() {
- const r = a.state.routeGeneration;
- if (o.value = null, await ve(), r !== a.state.routeGeneration || !n.value) return;
- const i = be(n.value, c.value);
- i.valid || (o.value = i.classification ?? "invalid", b(!1), m("Page unavailable", r));
+ async function A() {
+ const l = a.state.routeGeneration;
+ if (o.value = null, await Ae(), l !== a.state.routeGeneration || !n.value) return;
+ const f = Se(n.value, c.value);
+ f.valid || (o.value = f.classification ?? "invalid", _(!1), E("Page unavailable", l));
}
- function C(r) {
- const i = a.state.routeGeneration, s = r, p = s.detail?.state, A = s.target?.closest("[data-causeway-route-page]");
- if (!p || !A || !n.value?.contains(A)) return;
- let f = String(p.status ?? "terminal-error");
- if (f === "terminal-error") {
- const T = p.errors?.[0]?.extensions?.classification ?? p.errors?.[0]?.extensions?.code ?? p.error?.code;
- (T === "NOT_FOUND" || T === "ACCESS_DENIED") && (f = "unavailable");
+ function L(l) {
+ const f = a.state.routeGeneration, i = l, r = i.detail?.state, s = i.target?.closest("[data-causeway-route-page]");
+ if (!r || !s || !n.value?.contains(s)) return;
+ let y = String(r.status ?? "terminal-error");
+ if (y === "terminal-error") {
+ const T = r.errors?.[0]?.extensions?.classification ?? r.errors?.[0]?.extensions?.code ?? r.error?.code;
+ (T === "NOT_FOUND" || T === "ACCESS_DENIED") && (y = "unavailable");
}
- if (A.dataset.routeState = f, f === "ready" || f === "partial-error") {
- b(!1);
- const T = p.snapshot?.data?._meta?.title;
+ if (s.dataset.routeState = y, y === "ready" || y === "partial-error") {
+ _(!1);
+ const T = r.snapshot?.data?._meta?.title;
T && (document.title = String(T));
- for (const y of A.querySelectorAll("cw-collection:not([active])"))
- y.activate?.();
- m(f === "ready" ? "Page ready" : "Page ready with partial information", i);
- } else (f === "terminal-error" || f === "unavailable") && (b(!1), m("Page unavailable", i));
+ for (const v of s.querySelectorAll("cw-collection:not([active])"))
+ v.activate?.();
+ E(y === "ready" ? "Page ready" : "Page ready with partial information", f);
+ } else (y === "terminal-error" || y === "unavailable") && (_(!1), E("Page unavailable", f));
}
- return ge(() => t.fullPath, () => {
- a.state.routeGeneration += 1, b(!0), m("Loading page", a.state.routeGeneration), w();
- }, { immediate: !0 }), D(async () => {
- n.value?.addEventListener("causeway-object-context-state-change", C), await w();
- const r = n.value?.querySelector(
+ return me(() => t.fullPath, () => {
+ a.state.routeGeneration += 1, _(!0), E("Loading page", a.state.routeGeneration), A();
+ }, { immediate: !0 }), F(async () => {
+ n.value?.addEventListener("causeway-object-context-state-change", L), await A();
+ const l = n.value?.querySelector(
"cw-object-context[data-causeway-route-context]"
);
- r?.context?.state && r.dispatchEvent(new CustomEvent("causeway-object-context-state-change", {
+ l?.context?.state && l.dispatchEvent(new CustomEvent("causeway-object-context-state-change", {
bubbles: !0,
composed: !0,
- detail: { state: r.context.state, context: r.context }
+ detail: { state: l.context.state, context: l.context }
})), n.value?.querySelector("[data-causeway-route-page]")?.focus({ preventScroll: !0 });
- }), ne(() => n.value?.removeEventListener("causeway-object-context-state-change", C)), (r, i) => (R(), U("div", {
+ }), ce(() => n.value?.removeEventListener("causeway-object-context-state-change", L)), (l, f) => (I(), G("div", {
ref_key: "host",
ref: n,
class: "causeway-vue-object-route",
- "data-route-key": l.value
+ "data-route-key": d.value
}, [
- o.value ? (R(), U("section", ze, [...i[0] || (i[0] = [
- L("h1", null, "Page unavailable", -1),
- L("p", null, "The application page has an invalid semantic context boundary.", -1)
- ])])) : (R(), he(ye(g.value), {
- key: l.value,
+ o.value ? (I(), G("section", Xe, [...f[0] || (f[0] = [
+ x("h1", null, "Page unavailable", -1),
+ x("p", null, "The application page has an invalid semantic context boundary.", -1)
+ ])])) : (I(), be(Ee(g.value), {
+ key: d.value,
"logical-type-name": c.value.logicalTypeName,
"object-id": c.value.objectId,
- "route-key": l.value,
- onVnodeMounted: w,
- onVnodeUpdated: w
+ "route-key": d.value,
+ onVnodeMounted: A,
+ onVnodeUpdated: A
}, null, 8, ["logical-type-name", "object-id", "route-key"]))
- ], 8, We));
+ ], 8, Ye));
}
-}), j = Object.freeze({
+}), R = Object.freeze({
home: "causeway-home",
object: "causeway-object",
invalid: "causeway-invalid-route",
notFound: "causeway-not-found"
});
-function le(e, t, a, n) {
- return O({
+function fe(e, t, a, n) {
+ return j({
name: `Causeway${e.replace(/\W/gu, "")}Page`,
setup() {
- return () => S("section", {
+ return () => C("section", {
class: ["causeway-vue-route-page", "causeway-vue-status", a === "invalid-route" && "causeway-vue-status-danger"],
"data-route-state": a,
tabindex: -1,
role: n
- }, [S("h1", e), S("p", t)]);
+ }, [C("h1", e), C("p", t)]);
}
});
}
-const Me = le(
+const He = fe(
"Invalid route",
"The requested application route is invalid.",
"invalid-route",
"alert"
-), Fe = le(
+), Qe = fe(
"Page unavailable",
"The requested page is unavailable.",
"not-found",
"alert"
);
-function Qe(e = {}) {
+function ot(e = {}) {
return [
- { path: "/", name: j.home, component: e.homeComponent ?? Ge },
+ { path: "/", name: R.home, component: e.homeComponent ?? De },
{
path: "/object/:logicalTypeName/:objectId",
- name: j.object,
- component: De,
+ name: R.object,
+ component: Ze,
beforeEnter(t) {
try {
- return ce(t.path, "/"), !0;
+ return le(t.path, "/"), !0;
} catch {
- return { name: j.invalid, replace: !0 };
+ return { name: R.invalid, replace: !0 };
}
}
},
{
path: "/invalid-route",
- name: j.invalid,
- component: Me
+ name: R.invalid,
+ component: He
},
{
path: "/:pathMatch(.*)*",
- name: j.notFound,
- component: e.notFoundComponent ?? Fe
+ name: R.notFound,
+ component: e.notFoundComponent ?? Qe
}
];
}
export {
B as ACTION_REQUEST_EVENT,
- Q as ACTION_RESULT_EVENT,
- j as CAUSEWAY_ROUTE_NAMES,
- re as CAUSEWAY_VIEWER_KEY,
- $e as CausewayGenericObjectPage,
- Ge as CausewayHomePage,
- Me as CausewayInvalidRoutePage,
- Fe as CausewayNotFoundPage,
- De as CausewayObjectRoutePage,
- K as FRAMEWORK_LOGOUT_ACTION,
- Se as INVALID_ROUTE_MESSAGE,
- Ee as LocalResourceNavigationError,
- ee as MENU_BARS_STATE_EVENT,
- H as NAVIGATION_REQUEST_EVENT,
- J as OBJECT_CONTEXT_STATE_EVENT,
- G as ROUTE_CONTEXT_SELECTOR,
- me as ROUTE_INTERACTIONS_SELECTOR,
- He as bindCausewayShell,
- Le as canonicalObjectPath,
- Oe as canonicalRouteKey,
- M as canonicalRouterObjectPath,
- Qe as createCausewayRouteRecords,
- Xe as createCausewayVueViewer,
- Z as decodeRouteSegment,
- $ as encodeRouteSegment,
- ue as installSemanticBridge,
- V as isFrameworkLogoutAction,
- ke as isPageLoader,
- _e as navigateLocalResource,
- k as normalizeBasePath,
- Ue as normalizePageRegistry,
- ce as parseCanonicalObjectPath,
- se as presentSemanticResult,
- Y as removeFrameworkLogoutMenuActions,
- Te as resolveLocalResourceTarget,
- z as resolveResultOutlet,
- Ze as useCausewayShell,
- F as useCausewayViewer,
- be as validateRouteBoundary,
- oe as validateShellBoundary
+ ae as ACTION_RESULT_EVENT,
+ R as CAUSEWAY_ROUTE_NAMES,
+ se as CAUSEWAY_VIEWER_KEY,
+ Ke as CausewayGenericObjectPage,
+ De as CausewayHomePage,
+ He as CausewayInvalidRoutePage,
+ Qe as CausewayNotFoundPage,
+ Ze as CausewayObjectRoutePage,
+ H as FRAMEWORK_LOGOUT_ACTION,
+ je as INVALID_ROUTE_MESSAGE,
+ Ce as LocalResourceNavigationError,
+ oe as MENU_BARS_STATE_EVENT,
+ te as NAVIGATION_REQUEST_EVENT,
+ ne as OBJECT_CONTEXT_STATE_EVENT,
+ V as ROUTE_CONTEXT_SELECTOR,
+ _e as ROUTE_INTERACTIONS_SELECTOR,
+ nt as bindCausewayShell,
+ Re as canonicalObjectPath,
+ Pe as canonicalRouteKey,
+ K as canonicalRouterObjectPath,
+ ot as createCausewayRouteRecords,
+ tt as createCausewayVueViewer,
+ ee as decodeRouteSegment,
+ W as encodeRouteSegment,
+ pe as installSemanticBridge,
+ $ as isFrameworkLogoutAction,
+ We as isPageLoader,
+ xe as navigateLocalResource,
+ q as normalizeBasePath,
+ $e as normalizePageRegistry,
+ le as parseCanonicalObjectPath,
+ de as presentSemanticResult,
+ Q as removeFrameworkLogoutMenuActions,
+ Le as resolveLocalResourceTarget,
+ D as resolveResultOutlet,
+ at as useCausewayShell,
+ Y as useCausewayViewer,
+ Se as validateRouteBoundary,
+ ie as validateShellBoundary
};
//# sourceMappingURL=causeway-vue.js.map
diff --git a/viewers/webcomponents/vue/dist/causeway-vue.js.map b/viewers/webcomponents/vue/dist/causeway-vue.js.map
index 5e820a7e47f..15e9448daf8 100644
--- a/viewers/webcomponents/vue/dist/causeway-vue.js.map
+++ b/viewers/webcomponents/vue/dist/causeway-vue.js.map
@@ -1 +1 @@
-{"version":3,"file":"causeway-vue.js","sources":["../src/boundary.ts","../src/contracts.ts","../src/host-operation.ts","../src/local-resource.ts","../src/route-codec.ts","../src/policy.ts","../src/registry.ts","../src/plugin.ts","../src/components/HomePage.vue","../src/components/GenericObjectPage.vue","../src/components/ObjectRoutePage.vue","../src/routes.ts"],"sourcesContent":["/*\n * Licensed to the Apache Software Foundation (ASF) under one\n * or more contributor license agreements. [...]
\ No newline at end of file
+{"version":3,"file":"causeway-vue.js","sources":["../src/boundary.ts","../src/contracts.ts","../src/host-operation.ts","../src/local-resource.ts","../src/route-codec.ts","../src/policy.ts","../src/registry.ts","../src/plugin.ts","../src/components/HomePage.vue","../src/components/GenericObjectPage.vue","../src/components/ObjectRoutePage.vue","../src/routes.ts"],"sourcesContent":["/*\n * Licensed to the Apache Software Foundation (ASF) under one\n * or more contributor license agreements. [...]
\ No newline at end of file
diff --git a/viewers/webcomponents/vue/dist/contracts.d.ts b/viewers/webcomponents/vue/dist/contracts.d.ts
index e05f83b85e5..f897e6ba5a1 100644
--- a/viewers/webcomponents/vue/dist/contracts.d.ts
+++ b/viewers/webcomponents/vue/dist/contracts.d.ts
@@ -16,6 +16,11 @@ export interface CausewaySemanticResult {
readonly value?: unknown;
readonly reason?: string;
}
+export interface CausewayMenuRequest {
+ readonly role: 'primary' | 'secondary' | 'tertiary' | string;
+ readonly menuIndex: number;
+ readonly label: string;
+}
export interface CausewayActionRequest {
readonly actionId: string;
readonly serviceLogicalTypeName?: string;
@@ -35,7 +40,9 @@ export interface CausewayPolicyContext {
readonly routeGeneration: number;
}
export interface CausewayViewerPolicies {
+ readonly menuLabel?: (detail: CausewayMenuRequest, context: CausewayPolicyContext) => string | void;
readonly menuActionLabel?: (detail: CausewayActionRequest, context: CausewayPolicyContext) => string | void;
+ readonly menuActionAppearance?: (detail: CausewayActionRequest, context: CausewayPolicyContext) => string | void;
readonly action?: (detail: CausewayActionRequest, claim: CausewayEventClaim, context: CausewayPolicyContext) => boolean | void | Promise<boolean | void>;
readonly navigate?: (target: CausewayObjectTarget, claim: CausewayEventClaim, context: CausewayPolicyContext) => boolean | void | Promise<boolean | void>;
readonly home?: (entry: unknown, claim: CausewayEventClaim, context: CausewayPolicyContext) => boolean | void | Promise<boolean | void>;
diff --git a/viewers/webcomponents/vue/dist/contracts.d.ts.map b/viewers/webcomponents/vue/dist/contracts.d.ts.map
index 6c1931381a3..50c4eea9b17 100644
--- a/viewers/webcomponents/vue/dist/contracts.d.ts.map
+++ b/viewers/webcomponents/vue/dist/contracts.d.ts.map
@@ -1 +1 @@
-{"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../src/contracts.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAC,YAAY,EAAE,MAAM,EAAC,MAAM,KAAK,CAAC;AAC9C,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,YAAY,CAAC;AACvC,OAAO,KAAK,EAAC,wBAAwB,EAAE,yBAAyB,EAAC,MAAM,YAAY,CAAC;AAEpF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,2BAA2B;IAC1C [...]
\ No newline at end of file
+{"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../src/contracts.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAC,YAAY,EAAE,MAAM,EAAC,MAAM,KAAK,CAAC;AAC9C,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,YAAY,CAAC;AACvC,OAAO,KAAK,EAAC,wBAAwB,EAAE,yBAAyB,EAAC,MAAM,YAAY,CAAC;AAEpF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,2BAA2B;IAC1C [...]
\ No newline at end of file
diff --git a/viewers/webcomponents/vue/dist/policy.d.ts.map b/viewers/webcomponents/vue/dist/policy.d.ts.map
index 431d5a68da6..74cf0c1be6f 100644
--- a/viewers/webcomponents/vue/dist/policy.d.ts.map
+++ b/viewers/webcomponents/vue/dist/policy.d.ts.map
@@ -1 +1 @@
-{"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAMV,sBAAsB,EACtB,qBAAqB,EACtB,MAAM,aAAa,CAAC;AAKrB,eAAO,MAAM,wBAAwB,gCAAgC,CAAC;AACtE,eAAO,MAAM,oBAAoB,4BAA4B,CAAC;AAC9D,eAAO,MAAM,mBAAmB,2BAA2B,CAAC;AAC5D,eAAO,MAAM,0BAA0B,yCAAyC,CAAC;AACjF,eAAO,MAAM,qBAAqB,oCAAoC,CAAC;AAEvE,UAAU,kBAAkB;IAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,sBAAsB,CAAC;IACzC,QA [...]
\ No newline at end of file
+{"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAOV,sBAAsB,EACtB,qBAAqB,EACtB,MAAM,aAAa,CAAC;AAKrB,eAAO,MAAM,wBAAwB,gCAAgC,CAAC;AACtE,eAAO,MAAM,oBAAoB,4BAA4B,CAAC;AAC9D,eAAO,MAAM,mBAAmB,2BAA2B,CAAC;AAC5D,eAAO,MAAM,0BAA0B,yCAAyC,CAAC;AACjF,eAAO,MAAM,qBAAqB,oCAAoC,CAAC;AAEvE,UAAU,kBAAkB;IAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,sBAAsB,CAAC;IACzC,QA [...]
\ No newline at end of file
diff --git a/viewers/webcomponents/vue/src/contracts.ts b/viewers/webcomponents/vue/src/contracts.ts
index d782825d364..3c32ad39e56 100644
--- a/viewers/webcomponents/vue/src/contracts.ts
+++ b/viewers/webcomponents/vue/src/contracts.ts
@@ -39,6 +39,12 @@ export interface CausewaySemanticResult {
readonly reason?: string;
}
+export interface CausewayMenuRequest {
+ readonly role: 'primary' | 'secondary' | 'tertiary' | string;
+ readonly menuIndex: number;
+ readonly label: string;
+}
+
export interface CausewayActionRequest {
readonly actionId: string;
readonly serviceLogicalTypeName?: string;
@@ -61,7 +67,9 @@ export interface CausewayPolicyContext {
}
export interface CausewayViewerPolicies {
+ readonly menuLabel?: (detail: CausewayMenuRequest, context: CausewayPolicyContext) => string | void;
readonly menuActionLabel?: (detail: CausewayActionRequest, context: CausewayPolicyContext) => string | void;
+ readonly menuActionAppearance?: (detail: CausewayActionRequest, context: CausewayPolicyContext) => string | void;
readonly action?: (detail: CausewayActionRequest, claim: CausewayEventClaim, context: CausewayPolicyContext) => boolean | void | Promise<boolean | void>;
readonly navigate?: (target: CausewayObjectTarget, claim: CausewayEventClaim, context: CausewayPolicyContext) => boolean | void | Promise<boolean | void>;
readonly home?: (entry: unknown, claim: CausewayEventClaim, context: CausewayPolicyContext) => boolean | void | Promise<boolean | void>;
diff --git a/viewers/webcomponents/vue/src/policy.ts b/viewers/webcomponents/vue/src/policy.ts
index f7d722700ad..6fee5e10b0a 100644
--- a/viewers/webcomponents/vue/src/policy.ts
+++ b/viewers/webcomponents/vue/src/policy.ts
@@ -21,6 +21,7 @@ import type {
CausewayActionRequest,
CausewayEventClaim,
CausewayLocalResourceTarget,
+ CausewayMenuRequest,
CausewayObjectTarget,
CausewayPolicyContext,
CausewaySemanticResult,
@@ -100,6 +101,15 @@ function replacePresentation(outlet: HTMLElement, ...nodes: Node[]): void {
outlet.hidden = nodes.length === 0;
}
+function clearViewerOwnedResults(shell: HTMLElement): void {
+ const outlets = shell.querySelectorAll<HTMLElement>(
+ 'cw-action-results[data-causeway-page-result], cw-action-results[data-causeway-shell-result]'
+ );
+ for (const outlet of outlets) {
+ if (outlet.isConnected) replacePresentation(outlet);
+ }
+}
+
export function presentSemanticResult(outlet: HTMLElement, detail: ActionResultDetail): void {
const result = detail.result;
const heading = document.createElement('h2');
@@ -157,8 +167,19 @@ export function installSemanticBridge(runtime: CausewayViewerRuntime, shell: HTM
const resumedActionEvents = new WeakSet<Event>();
const menuBoundary = shell.querySelector<HTMLElement & {
excludeAction?: (detail: CausewayActionRequest) => boolean;
+ menuLabel?: (detail: CausewayMenuRequest) => string | void;
actionLabel?: (detail: CausewayActionRequest) => string | void;
+ actionAppearance?: (detail: CausewayActionRequest) => string | void;
}>('cw-menubars');
+ const mappedMenuLabel = (detail: CausewayMenuRequest): string | undefined => {
+ try {
+ const label = runtime.policies.menuLabel?.(detail, context(runtime));
+ return typeof label === 'string' && label.trim() ? label : undefined;
+ } catch (error) {
+ runtime.policies.error?.(error, context(runtime));
+ return undefined;
+ }
+ };
const mappedActionLabel = (detail: CausewayActionRequest): string | undefined => {
try {
const label = runtime.policies.menuActionLabel?.(detail, context(runtime));
@@ -168,6 +189,15 @@ export function installSemanticBridge(runtime: CausewayViewerRuntime, shell: HTM
return undefined;
}
};
+ const mappedActionAppearance = (detail: CausewayActionRequest): string | undefined => {
+ try {
+ const appearance = runtime.policies.menuActionAppearance?.(detail, context(runtime));
+ return typeof appearance === 'string' && appearance.trim() ? appearance : undefined;
+ } catch (error) {
+ runtime.policies.error?.(error, context(runtime));
+ return undefined;
+ }
+ };
const hasFrameworkLogoutPresentation = () => Boolean(mappedActionLabel({
serviceLogicalTypeName: 'causeway.security.LogoutMenu',
actionId: 'logout'
@@ -176,7 +206,9 @@ export function installSemanticBridge(runtime: CausewayViewerRuntime, shell: HTM
&& !Boolean(mappedActionLabel(detail));
if (menuBoundary) {
menuBoundary.excludeAction = excludeFrameworkLogout;
+ menuBoundary.menuLabel = mappedMenuLabel;
menuBoundary.actionLabel = mappedActionLabel;
+ menuBoundary.actionAppearance = mappedActionAppearance;
}
let unscopedDestination: HTMLElement | null = null;
let active = true;
@@ -247,22 +279,39 @@ export function installSemanticBridge(runtime: CausewayViewerRuntime, shell: HTM
const onResult = (rawEvent: Event) => {
const event = rawEvent as CustomEvent<ActionResultDetail>;
const detail = event.detail ?? {};
+ const generation = runtime.state.routeGeneration;
+ const source = event.target as {dismissResult?: () => void} | null;
void (async () => {
const claim = createClaim();
const handled = await runtime.policies.result?.(detail, claim, context(runtime));
if (handled === true) claim.claim();
- if (claim.claimed) return;
+ if (claim.claimed || !active || generation !== runtime.state.routeGeneration) return;
+ const dismissSource = () => {
+ try {
+ source?.dismissResult?.();
+ } catch (error) {
+ runtime.policies.error?.(error, context(runtime));
+ }
+ };
+ clearViewerOwnedResults(shell);
if (detail.result?.kind === 'local-resource') {
navigateLocalResource(detail.result.value as CausewayLocalResourceTarget, {applicationBase: runtime.applicationResourceBase});
- (event.target as {dismissResult?: () => void} | null)?.dismissResult?.();
+ dismissSource();
return;
}
const identity = objectIdentity(detail.result);
- if (identity) return applyNavigation(runtime, identity);
- const destination = detail.context && destinations.get(detail.context) || unscopedDestination || resolveResultOutlet(shell);
+ if (identity) {
+ dismissSource();
+ await applyNavigation(runtime, identity);
+ return;
+ }
+ const remembered = detail.context && destinations.get(detail.context) || unscopedDestination;
+ const destination = remembered?.isConnected ? remembered : resolveResultOutlet(shell);
+ if (detail.context) destinations.delete(detail.context);
+ unscopedDestination = null;
if (detail.result?.kind === 'void') await refreshAfterVoid(runtime, detail);
else presentSemanticResult(destination, detail);
- (event.target as {dismissResult?: () => void} | null)?.dismissResult?.();
+ dismissSource();
})().catch(error => runtime.policies.error?.(error, context(runtime)));
};
const onMenuState = () => queueMicrotask(() => {
@@ -276,7 +325,9 @@ export function installSemanticBridge(runtime: CausewayViewerRuntime, shell: HTM
return () => {
active = false;
if (menuBoundary?.excludeAction === excludeFrameworkLogout) menuBoundary.excludeAction = undefined;
+ if (menuBoundary?.menuLabel === mappedMenuLabel) menuBoundary.menuLabel = undefined;
if (menuBoundary?.actionLabel === mappedActionLabel) menuBoundary.actionLabel = undefined;
+ if (menuBoundary?.actionAppearance === mappedActionAppearance) menuBoundary.actionAppearance = undefined;
shell.removeEventListener(ACTION_REQUEST_EVENT, onActionRequest, {capture: true});
shell.removeEventListener(NAVIGATION_REQUEST_EVENT, onNavigation);
shell.removeEventListener(ACTION_RESULT_EVENT, onResult);
diff --git a/viewers/webcomponents/vue/test/policy-integration.test.ts b/viewers/webcomponents/vue/test/policy-integration.test.ts
index 11411d835c4..c3f34763e51 100644
--- a/viewers/webcomponents/vue/test/policy-integration.test.ts
+++ b/viewers/webcomponents/vue/test/policy-integration.test.ts
@@ -104,16 +104,30 @@ describe('semantic policy bridge', () => {
expect(collection.result).toEqual({kind: 'collection', value: [{_meta: {id: '1'}}]});
});
- it('routes object action results and removes listeners on dispose', async () => {
+ it('routes entity and view-model action results, clears stale outlets, dismisses sources, and removes listeners', async () => {
const viewer = await runtime();
const shell = authoredShell();
+ const outlet = shell.querySelector<HTMLElement>('[data-causeway-shell-result]')!;
+ outlet.replaceChildren(document.createTextNode('stale result'));
+ outlet.hidden = false;
+ const source = document.createElement('cw-service-action') as HTMLElement & {dismissResult: ReturnType<typeof vi.fn>};
+ source.dismissResult = vi.fn();
+ shell.append(source);
const dispose = installSemanticBridge(viewer, shell);
- shell.dispatchEvent(new CustomEvent(ACTION_RESULT_EVENT, {
- bubbles: true,
- detail: {result: {kind: 'object', value: {_meta: {logicalTypeName: 'petclinic.Pet', id: 's_pet-1'}}}}
- }));
- await new Promise(resolve => setTimeout(resolve, 0));
- expect(viewer.router.currentRoute.value.path).toBe('/object/petclinic.Pet/s_pet-1');
+ for (const target of [
+ {logicalTypeName: 'petclinic.Pet', id: 's_pet-1'},
+ {logicalTypeName: 'causeway.applib.UserMemento', id: 's_user-sven'}
+ ]) {
+ source.dispatchEvent(new CustomEvent(ACTION_RESULT_EVENT, {
+ bubbles: true,
+ detail: {result: {kind: 'object', value: {_meta: target}}}
+ }));
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(viewer.router.currentRoute.value.path).toBe(`/object/${target.logicalTypeName}/${target.id}`);
+ expect(outlet.hidden).toBe(true);
+ expect(outlet.textContent).toBe('');
+ }
+ expect(source.dismissResult).toHaveBeenCalledTimes(2);
dispose();
await viewer.router.replace('/');
shell.dispatchEvent(new CustomEvent(NAVIGATION_REQUEST_EVENT, {
@@ -124,6 +138,24 @@ describe('semantic policy bridge', () => {
expect(viewer.router.currentRoute.value.path).toBe('/');
});
+ it('presents identity-incomplete object results as bounded replacements and dismisses the source', async () => {
+ const viewer = await runtime();
+ const shell = authoredShell();
+ const source = document.createElement('cw-service-action') as HTMLElement & {dismissResult: ReturnType<typeof vi.fn>};
+ source.dismissResult = vi.fn();
+ shell.append(source);
+ const dispose = installSemanticBridge(viewer, shell);
+ source.dispatchEvent(new CustomEvent(ACTION_RESULT_EVENT, {
+ bubbles: true,
+ detail: {actionId: 'me', result: {kind: 'object', value: {_meta: {logicalTypeName: 'causeway.applib.UserMemento', title: 'sven'}}}}
+ }));
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(viewer.router.currentRoute.value.path).toBe('/');
+ expect(shell.querySelector('[data-causeway-shell-result]')?.textContent).toContain('This result cannot be presented.');
+ expect(source.dismissResult).toHaveBeenCalledOnce();
+ dispose();
+ });
+
it('fails framework Logout closed before invocation and removes its menu control', async () => {
const viewer = await runtime();
const shell = authoredShell();
@@ -135,7 +167,9 @@ describe('semantic policy bridge', () => {
expect(region.isConnected).toBe(false);
const menuBoundary = shell.querySelector('cw-menubars') as HTMLElement & {
excludeAction?: (detail: object) => boolean;
+ menuLabel?: (detail: object) => string | void;
actionLabel?: (detail: object) => string | void;
+ actionAppearance?: (detail: object) => string | void;
};
expect(menuBoundary.excludeAction?.({serviceLogicalTypeName: 'causeway.security.LogoutMenu', actionId: 'logout'})).toBe(true);
expect(menuBoundary.excludeAction?.({serviceLogicalTypeName: 'example.LogoutMenu', actionId: 'logout'})).toBe(false);
@@ -152,24 +186,36 @@ describe('semantic policy bridge', () => {
expect(shell.querySelector('[data-causeway-route-announcement]')?.textContent).toContain('host authentication');
dispose();
expect(menuBoundary.excludeAction).toBeUndefined();
+ expect(menuBoundary.menuLabel).toBeUndefined();
expect(menuBoundary.actionLabel).toBeUndefined();
+ expect(menuBoundary.actionAppearance).toBeUndefined();
});
- it('retains and relabels exact framework Logout only with an explicit host menu policy', async () => {
+ it('maps utility title and exact framework Logout presentation only with explicit host menu policy', async () => {
+ const menuLabel = vi.fn((detail: {role: string; label: string}) => detail.role === 'tertiary' && detail.label === 'Account'
+ ? 'sven' : undefined);
const menuActionLabel = vi.fn((detail: {serviceLogicalTypeName?: string; actionId: string}) => detail.serviceLogicalTypeName === 'causeway.security.LogoutMenu'
&& detail.actionId === 'logout' ? 'Sign out' : undefined);
- const viewer = await runtime({menuActionLabel});
+ const menuActionAppearance = vi.fn((detail: {serviceLogicalTypeName?: string; actionId: string}) => detail.serviceLogicalTypeName === 'causeway.security.LogoutMenu'
+ && detail.actionId === 'logout' ? 'sign-out' : undefined);
+ const viewer = await runtime({menuLabel, menuActionLabel, menuActionAppearance});
const shell = authoredShell();
const dispose = installSemanticBridge(viewer, shell);
const menuBoundary = shell.querySelector('cw-menubars') as HTMLElement & {
excludeAction?: (detail: object) => boolean;
+ menuLabel?: (detail: object) => string | void;
actionLabel?: (detail: object) => string | void;
+ actionAppearance?: (detail: object) => string | void;
};
const logout = {serviceLogicalTypeName: 'causeway.security.LogoutMenu', actionId: 'logout'};
const similar = {serviceLogicalTypeName: 'example.LogoutMenu', actionId: 'logout'};
expect(menuBoundary.excludeAction?.(logout)).toBe(false);
+ expect(menuBoundary.menuLabel?.({role: 'tertiary', label: 'Account'})).toBe('sven');
+ expect(menuBoundary.menuLabel?.({role: 'primary', label: 'Account'})).toBeUndefined();
expect(menuBoundary.actionLabel?.(logout)).toBe('Sign out');
expect(menuBoundary.actionLabel?.(similar)).toBeUndefined();
+ expect(menuBoundary.actionAppearance?.(logout)).toBe('sign-out');
+ expect(menuBoundary.actionAppearance?.(similar)).toBeUndefined();
expect(menuActionLabel).toHaveBeenCalled();
dispose();
});
@@ -204,18 +250,48 @@ describe('semantic policy bridge', () => {
disposeAsync();
});
- it('lets the result policy claim local-resource navigation', async () => {
+ it('lets the result policy claim local-resource navigation without clearing or dismissing application-owned state', async () => {
const result = vi.fn((_detail, claim) => claim.claim());
const viewer = await runtime({result});
const shell = authoredShell();
+ const outlet = shell.querySelector<HTMLElement>('[data-causeway-shell-result]')!;
+ outlet.textContent = 'application-owned';
+ const source = document.createElement('cw-service-action') as HTMLElement & {dismissResult: ReturnType<typeof vi.fn>};
+ source.dismissResult = vi.fn();
+ shell.append(source);
const dispose = installSemanticBridge(viewer, shell);
- shell.dispatchEvent(new CustomEvent(ACTION_RESULT_EVENT, {
+ source.dispatchEvent(new CustomEvent(ACTION_RESULT_EVENT, {
bubbles: true,
detail: {result: {kind: 'local-resource', value: {path: '/guide', openUrlStrategy: 'SAME_WINDOW'}}}
}));
await new Promise(resolve => setTimeout(resolve, 0));
expect(result).toHaveBeenCalledOnce();
expect(viewer.router.currentRoute.value.path).toBe('/');
+ expect(outlet.textContent).toBe('application-owned');
+ expect(source.dismissResult).not.toHaveBeenCalled();
+ dispose();
+ });
+
+ it('ignores an asynchronous result after its route generation becomes obsolete', async () => {
+ let release!: () => void;
+ const result = vi.fn(() => new Promise<void>(resolve => { release = resolve; }));
+ const viewer = await runtime({result});
+ const shell = authoredShell();
+ const outlet = shell.querySelector<HTMLElement>('[data-causeway-shell-result]')!;
+ outlet.textContent = 'current route result';
+ const source = document.createElement('cw-service-action') as HTMLElement & {dismissResult: ReturnType<typeof vi.fn>};
+ source.dismissResult = vi.fn();
+ shell.append(source);
+ const dispose = installSemanticBridge(viewer, shell);
+ source.dispatchEvent(new CustomEvent(ACTION_RESULT_EVENT, {
+ bubbles: true,
+ detail: {result: {kind: 'scalar', value: 'obsolete'}}
+ }));
+ viewer.state.routeGeneration += 1;
+ release();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(outlet.textContent).toBe('current route result');
+ expect(source.dismissResult).not.toHaveBeenCalled();
dispose();
});
});