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/08/31 13:24:39 UTC
(causeway) 02/06: feat(webcomponents): add icons to domain object links
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 aa60cb7ae7ec0a5842b3db3882201bb526ffd19d
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Mon Aug 31 13:06:12 2026 +0200
feat(webcomponents): add icons to domain object links
---
.../changes/add-domain-object-link-icons/tasks.md | 20 ++++++-------
.../common/query/meta/CommonMetaBreadcrumbs.java | 15 +++++++---
.../common/query/meta/CommonMetaFetcher.java | 30 ++++++++++++++++----
.../query/meta/CommonMetaBreadcrumbsTest.java | 30 ++++++++++++++++----
.../common/query/meta/CommonMetaFetcherTest.java | 32 +++++++++++++++++----
...adata_is_additive_facet_driven_and_shared._.gql | 1 +
.../test/e2e/special/MemberMetadata_IntegTest.java | 4 ++-
viewers/graphql/test/src/test/resources/schema.gql | 3 +-
viewers/webcomponents/foundation/USAGE.adoc | 2 ++
.../foundation/src/breadcrumbs-element.mjs | 2 +-
.../foundation/src/collection-element.mjs | 4 +--
.../foundation/src/collection-grid-projection.mjs | 5 ++--
.../foundation/src/component-styles.css | 10 ++++++-
.../foundation/src/component-styles.mjs | 10 ++++++-
.../foundation/src/interaction-operations.mjs | 2 +-
.../foundation/src/object-context-controller.mjs | 8 ++++--
.../foundation/src/object-header-element.mjs | 9 ++++--
.../foundation/src/object-link-element.mjs | 21 +++++++++++++-
.../foundation/src/value-renderers.mjs | 3 +-
.../foundation/test/collection.test.mjs | 13 ++++++++-
.../foundation/test/component-styles.test.mjs | 5 ++++
.../foundation/test/components.test.mjs | 29 ++++++++++++++++++-
.../foundation/test/domain-components.test.mjs | 19 +++++++++++++
.../test/fixtures/rich-schema-fixture.mjs | 8 ++++--
.../foundation/test/identity-selection.test.mjs | 12 ++++----
.../foundation/test/object-context.test.mjs | 33 ++++++++++++++++++++--
.../foundation/test/value-renderers.test.mjs | 8 +++++-
.../petclinic/PetClinicHtmxPlaywrightTest.java | 30 ++++++++++++++++++++
28 files changed, 307 insertions(+), 61 deletions(-)
diff --git a/openspec/changes/add-domain-object-link-icons/tasks.md b/openspec/changes/add-domain-object-link-icons/tasks.md
index e3b7afdcd02..254dc13ba1e 100644
--- a/openspec/changes/add-domain-object-link-icons/tasks.md
+++ b/openspec/changes/add-domain-object-link-icons/tasks.md
@@ -1,21 +1,21 @@
## 1. Rich GraphQL breadcrumb metadata
-- [ ] 1.1 Add policy-aware nullable icon metadata to the shared navigable breadcrumb entry and derive ancestor URLs through the existing configured metadata resource path.
-- [ ] 1.2 Extend breadcrumb resolver, schema, introspection, and traversal tests for enabled, unavailable, and forbidden icon metadata.
+- [x] 1.1 Add policy-aware nullable icon metadata to the shared navigable breadcrumb entry and derive ancestor URLs through the existing configured metadata resource path.
+- [x] 1.2 Extend breadcrumb resolver, schema, introspection, and traversal tests for enabled, unavailable, and forbidden icon metadata.
## 2. Semantic object-link presentation
-- [ ] 2.1 Extend `<cw-object-link>` with optional decorative icon rendering, bounded failure fallback, and unchanged semantic navigation payload behavior.
-- [ ] 2.2 Add shared responsive icon sizing and alignment styles and document the public attribute, classes, and theme variables.
-- [ ] 2.3 Render `<cw-object-header>` titles as same-object semantic links while preserving one heading and the existing secondary identity.
+- [x] 2.1 Extend `<cw-object-link>` with optional decorative icon rendering, bounded failure fallback, and unchanged semantic navigation payload behavior.
+- [x] 2.2 Add shared responsive icon sizing and alignment styles and document the public attribute, classes, and theme variables.
+- [x] 2.3 Render `<cw-object-header>` titles as same-object semantic links while preserving one heading and the existing secondary identity.
## 3. Targeted icon propagation
-- [ ] 3.1 Include advertised icon fields in header and reusable object-result metadata selections without breaking older or restricted schemas.
-- [ ] 3.2 Propagate returned icon metadata through property-reference, collection list/grid, hydrated-row, and breadcrumb object links.
-- [ ] 3.3 Add foundation tests for targeted selections, current-object self-links, property and collection links, breadcrumb links, decorative accessibility, and missing or failed icon fallback.
+- [x] 3.1 Include advertised icon fields in header and reusable object-result metadata selections without breaking older or restricted schemas.
+- [x] 3.2 Propagate returned icon metadata through property-reference, collection list/grid, hydrated-row, and breadcrumb object links.
+- [x] 3.3 Add foundation tests for targeted selections, current-object self-links, property and collection links, breadcrumb links, decorative accessibility, and missing or failed icon fallback.
## 4. Application verification
-- [ ] 4.1 Extend Petclinic browser coverage for icon-bearing current-object, property-reference, collection-row, and breadcrumb links plus canonical self-navigation.
-- [ ] 4.2 Run focused GraphQL tests, foundation Node and Maven tests, Petclinic browser tests, and strict OpenSpec validation; resolve any regressions.
+- [x] 4.1 Extend Petclinic browser coverage for icon-bearing current-object, property-reference, collection-row, and breadcrumb links plus canonical self-navigation.
+- [x] 4.2 Run focused GraphQL tests, foundation Node and Maven tests, Petclinic browser tests, and strict OpenSpec validation; resolve any regressions.
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbs.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbs.java
index 8679140d619..795b4997854 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbs.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbs.java
@@ -27,6 +27,7 @@
import graphql.schema.GraphQLNonNull;
import graphql.schema.GraphQLObjectType;
+import org.apache.causeway.core.config.CausewayConfiguration;
import org.apache.causeway.viewer.graphql.model.context.Context;
import org.apache.causeway.viewer.graphql.model.domain.Element;
@@ -50,9 +51,9 @@ private static GraphQLObjectType entryType(final Context context) {
if (existing.isPresent()) {
return existing.get();
}
- final var type = GraphQLObjectType.newObject()
+ final var typeBuilder = GraphQLObjectType.newObject()
.name(ENTRY_TYPE_NAME)
- .description("Bookmark identity and title for one navigable ancestor.")
+ .description("Bookmark identity, title and optional icon for one navigable ancestor.")
.field(newFieldDefinition()
.name("logicalTypeName")
.type(GraphQLNonNull.nonNull(Scalars.GraphQLString)))
@@ -61,8 +62,14 @@ private static GraphQLObjectType entryType(final Context context) {
.type(GraphQLNonNull.nonNull(Scalars.GraphQLString)))
.field(newFieldDefinition()
.name("title")
- .type(GraphQLNonNull.nonNull(Scalars.GraphQLString)))
- .build();
+ .type(GraphQLNonNull.nonNull(Scalars.GraphQLString)));
+ if (context.causewayConfiguration.viewer().graphql().resources().effectiveStructuralMetadataResponseType()
+ != CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN) {
+ typeBuilder.field(newFieldDefinition()
+ .name("icon")
+ .type(Scalars.GraphQLString));
+ }
+ final var type = typeBuilder.build();
context.graphQLTypeRegistry.addTypeIfNotAlreadyPresent(type);
return context.graphQLTypeRegistry.lookup(ENTRY_TYPE_NAME, GraphQLObjectType.class).orElse(type);
}
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java
index 2e3f0bdc8b1..1ba94dbfef0 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java
@@ -102,7 +102,8 @@ public List<Map<String, String>> breadcrumbs() {
current.getPojo(),
pojo -> objectManager.adapt(pojo).objSpec().getNavigableParent(pojo),
bookmarkService::bookmarkFor,
- pojo -> objectManager.adapt(pojo).getTitle()))
+ pojo -> objectManager.adapt(pojo).getTitle(),
+ this::breadcrumbIcon))
.orElseGet(List::of);
}
@@ -111,7 +112,8 @@ static List<Map<String, String>> traverseBreadcrumbs(
final Object currentPojo,
final Function<Object, Object> parentResolver,
final Function<Object, Optional<Bookmark>> bookmarkResolver,
- final Function<Object, String> titleResolver) {
+ final Function<Object, String> titleResolver,
+ final Function<Bookmark, String> iconResolver) {
final var seen = new HashSet<Bookmark>();
seen.add(currentBookmark);
final var ancestors = new ArrayList<Map<String, String>>();
@@ -149,10 +151,15 @@ static List<Map<String, String>> traverseBreadcrumbs(
} catch (RuntimeException ex) {
throw breadcrumbFailure("GRAPHQL_BREADCRUMB_TITLE_FAILED", "Navigable parent title resolution failed.");
}
- ancestors.add(Map.of(
- "logicalTypeName", parentBookmark.get().logicalTypeName(),
- "id", parentBookmark.get().identifier(),
- "title", title));
+ final var ancestor = new java.util.LinkedHashMap<String, String>();
+ ancestor.put("logicalTypeName", parentBookmark.get().logicalTypeName());
+ ancestor.put("id", parentBookmark.get().identifier());
+ ancestor.put("title", title);
+ final var icon = iconResolver.apply(parentBookmark.get());
+ if (icon != null) {
+ ancestor.put("icon", icon);
+ }
+ ancestors.add(Map.copyOf(ancestor));
pojo = parentPojo;
}
Collections.reverse(ancestors);
@@ -185,6 +192,17 @@ public String icon() {
return resource("icon");
}
+ private String breadcrumbIcon(final Bookmark ancestor) {
+ if (causewayConfiguration.viewer().graphql().resources().effectiveStructuralMetadataResponseType()
+ == CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN) {
+ return null;
+ }
+ return resourcePath.metadata(
+ ancestor,
+ causewayConfiguration.viewer().graphql().metaData().fieldName(),
+ "icon");
+ }
+
private String resource(final String resource) {
return managedObject()
.flatMap(Bookmarkable::getBookmark)
diff --git a/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbsTest.java b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbsTest.java
index 132d3268ea9..6d6f161e992 100644
--- a/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbsTest.java
+++ b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbsTest.java
@@ -36,12 +36,13 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
class CommonMetaBreadcrumbsTest {
@Test
- void usesOneSharedEntryTypeWithRequiredIdentityAndTitle() {
- final var context = context();
+ void usesOneSharedEntryTypeWithRequiredIdentityTitleAndOptionalIcon() {
+ final var context = context(CausewayConfiguration.Viewer.Graphql.ResponseType.DIRECT);
final var first = new CommonMetaBreadcrumbs(context);
final var second = new CommonMetaBreadcrumbs(context);
@@ -55,22 +56,39 @@ void usesOneSharedEntryTypeWithRequiredIdentityAndTitle() {
CommonMetaBreadcrumbs.ENTRY_TYPE_NAME,
graphql.schema.GraphQLObjectType.class).orElseThrow();
assertThat(entry.getFieldDefinitions())
- .extracting(field -> field.getName() + ":" + field.getType())
+ .extracting(field -> field.getName() + ":" + graphql.schema.GraphQLTypeUtil.simplePrint(field.getType()))
.containsExactly(
"logicalTypeName:String!",
"id:String!",
- "title:String!");
+ "title:String!",
+ "icon:String");
}
- private static Context context() {
+ @Test
+ void omitsIconWhenStructuralMetadataIsForbidden() {
+ final var context = context(CausewayConfiguration.Viewer.Graphql.ResponseType.FORBIDDEN);
+ new CommonMetaBreadcrumbs(context);
+
+ final var entry = context.graphQLTypeRegistry.lookup(
+ CommonMetaBreadcrumbs.ENTRY_TYPE_NAME,
+ graphql.schema.GraphQLObjectType.class).orElseThrow();
+ assertThat(entry.getFieldDefinitions())
+ .extracting(field -> field.getName())
+ .containsExactly("logicalTypeName", "id", "title");
+ }
+
+ private static Context context(final CausewayConfiguration.Viewer.Graphql.ResponseType responseType) {
@SuppressWarnings("unchecked")
final Provider<Context> contextProvider = mock(Provider.class);
+ final var configuration = mock(CausewayConfiguration.class, Answers.RETURNS_DEEP_STUBS);
+ when(configuration.viewer().graphql().resources().effectiveStructuralMetadataResponseType())
+ .thenReturn(responseType);
return new Context(
mock(BookmarkService.class),
mock(SpecificationLoader.class),
mock(TypeMapper.class),
mock(ServiceRegistry.class),
- mock(CausewayConfiguration.class, Answers.RETURNS_DEEP_STUBS),
+ configuration,
mock(CausewaySystemEnvironment.class),
mock(ObjectManager.class),
new GraphQLTypeRegistry(contextProvider),
diff --git a/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcherTest.java b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcherTest.java
index 5d5aad04e3b..911737a6bd1 100644
--- a/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcherTest.java
+++ b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcherTest.java
@@ -48,6 +48,23 @@ void returnsEmptySingleAndRootOrderedAncestorChains() {
.containsExactly("owner-1", "pet-1");
}
+ @Test
+ void includesAvailableAncestorIconsWithoutMakingThemIdentity() {
+ final var breadcrumbs = CommonMetaFetcher.traverseBreadcrumbs(
+ bookmark("Pet", "pet-1"),
+ "pet",
+ pojo -> "pet".equals(pojo) ? "owner" : null,
+ pojo -> Optional.of(bookmark("Owner", "owner-1")),
+ pojo -> "Owner title",
+ ancestor -> "/graphql/" + ancestor.identifier() + "/_meta/icon");
+
+ assertThat(breadcrumbs).containsExactly(Map.of(
+ "logicalTypeName", "test.Owner",
+ "id", "owner-1",
+ "title", "Owner title",
+ "icon", "/graphql/owner-1/_meta/icon"));
+ }
+
@Test
void stopsBeforeAnUnbookmarkableParent() {
assertThat(CommonMetaFetcher.traverseBreadcrumbs(
@@ -55,7 +72,8 @@ void stopsBeforeAnUnbookmarkableParent() {
"child",
pojo -> "child".equals(pojo) ? "transient" : null,
pojo -> Optional.empty(),
- pojo -> "secret transient title"))
+ pojo -> "secret transient title",
+ bookmark -> "/icons/" + bookmark.identifier()))
.isEmpty();
}
@@ -67,7 +85,8 @@ void rejectsCyclesDepthOverflowAndThrowingParentWithoutSensitiveDetails() {
"child",
cyclicParents::get,
pojo -> Optional.of(bookmark(capitalize((String) pojo), pojo + "-1")),
- pojo -> "Title " + pojo))
+ pojo -> "Title " + pojo,
+ bookmark -> "/icons/" + bookmark.identifier()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("GRAPHQL_BREADCRUMB_CYCLE");
@@ -80,7 +99,8 @@ void rejectsCyclesDepthOverflowAndThrowingParentWithoutSensitiveDetails() {
0,
parents::get,
pojo -> Optional.of(bookmark("Node", pojo.toString())),
- pojo -> "Node " + pojo))
+ pojo -> "Node " + pojo,
+ bookmark -> "/icons/" + bookmark.identifier()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("GRAPHQL_BREADCRUMB_DEPTH_EXCEEDED")
.hasMessageContaining("32");
@@ -92,7 +112,8 @@ void rejectsCyclesDepthOverflowAndThrowingParentWithoutSensitiveDetails() {
throw new IllegalArgumentException("secret domain exception");
},
pojo -> Optional.empty(),
- Object::toString))
+ Object::toString,
+ bookmark -> "/icons/" + bookmark.identifier()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("GRAPHQL_BREADCRUMB_PARENT_FAILED")
.hasMessageNotContaining("secret domain exception");
@@ -107,7 +128,8 @@ private static java.util.List<Map<String, String>> traverse(
current,
parents::get,
pojo -> Optional.ofNullable(bookmarks.get(pojo)),
- pojo -> "Title " + pojo);
+ pojo -> "Title " + pojo,
+ bookmark -> null);
}
private static Bookmark bookmark(final String type, final String id) {
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.breadcrumb_metadata_is_additive_facet_driven_and_shared._.gql b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.breadcrumb_metadata_is_additive_facet_driven_and_shared._.gql
index 93e7b770eb1..c0bcc557b81 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.breadcrumb_metadata_is_additive_facet_driven_and_shared._.gql
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.breadcrumb_metadata_is_additive_facet_driven_and_shared._.gql
@@ -19,6 +19,7 @@
logicalTypeName
id
title
+ icon
}
}
}
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.java b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.java
index 7a8153c4919..02b0142e119 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.java
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.java
@@ -86,7 +86,7 @@ void breadcrumb_metadata_is_additive_facet_driven_and_shared() throws Exception
.isTrue();
assertThat(root.at("/data/breadcrumbType/fields"))
.extracting(node -> node.get("name").stringValue())
- .containsExactlyInAnyOrder("logicalTypeName", "id", "title");
+ .containsExactlyInAnyOrder("logicalTypeName", "id", "title", "icon");
assertThat(metadata.get("title").stringValue()).isEqualTo("Untitled Staff Member");
assertThat(metadata.get("breadcrumbs"))
.singleElement()
@@ -95,6 +95,8 @@ void breadcrumb_metadata_is_additive_facet_driven_and_shared() throws Exception
.isEqualTo("university.dept.Department");
assertThat(node.get("id").stringValue()).isNotBlank();
assertThat(node.get("title").stringValue()).isEqualTo("Untitled Department");
+ assertThat(node.get("icon").stringValue())
+ .matches("/graphql/object/university.dept.Department:(\\d+)/_meta/icon");
});
assertThat(StaffMember.departmentReadCount())
.as("selected breadcrumb metadata evaluates the navigable parent")
diff --git a/viewers/graphql/test/src/test/resources/schema.gql b/viewers/graphql/test/src/test/resources/schema.gql
index 9f0a25fba64..42704c95638 100644
--- a/viewers/graphql/test/src/test/resources/schema.gql
+++ b/viewers/graphql/test/src/test/resources/schema.gql
@@ -195,8 +195,9 @@ type RichMemberMetadata {
typicalLength: Int
}
-"Bookmark identity and title for one navigable ancestor."
+"Bookmark identity, title and optional icon for one navigable ancestor."
type RichNavigableBreadcrumb {
+ icon: String
id: String!
logicalTypeName: String!
title: String!
diff --git a/viewers/webcomponents/foundation/USAGE.adoc b/viewers/webcomponents/foundation/USAGE.adoc
index 88ab88a5069..ff693f38b41 100644
--- a/viewers/webcomponents/foundation/USAGE.adoc
+++ b/viewers/webcomponents/foundation/USAGE.adoc
@@ -329,6 +329,8 @@ Causeway retains the visible label, description, disabled reason, authoritative
Application renderers, protected values, nulls, references, resources, LOBs, offset, zoned, and legacy temporals, custom values, collections, unsupported values, and unqualified precision retain their established native or application renderer.
`<cw-object-link>` renders title and bookmark identity as a native button with link semantics.
+Its optional `icon` attribute renders a decorative image before the title through the `causeway-object-link-icon` class, with size and fit controlled by `--causeway-object-icon-size` and `--causeway-object-icon-fit`.
+A missing or failed icon leaves the title link usable and does not change its accessible name.
Enabled activation publishes `causeway-navigation-request` with `{target: {logicalTypeName, id, title}, sourceContext}`.
The event bubbles, crosses composed component boundaries, and is cancelable.
The component does not invent a URL or select a router.
diff --git a/viewers/webcomponents/foundation/src/breadcrumbs-element.mjs b/viewers/webcomponents/foundation/src/breadcrumbs-element.mjs
index 25c5d3185a6..44a3831b3fe 100644
--- a/viewers/webcomponents/foundation/src/breadcrumbs-element.mjs
+++ b/viewers/webcomponents/foundation/src/breadcrumbs-element.mjs
@@ -52,7 +52,7 @@ export class CausewayBreadcrumbsElement extends CausewayContextConsumerElement {
this.hidden = false;
const currentTitle = metadata.title || `${metadata.logicalTypeName ?? ''}:${metadata.id ?? ''}`;
const ancestorItems = ancestors.map(ancestor => `<li class="causeway-breadcrumbs-item">
- <cw-object-link logical-type="${escapeHtml(ancestor.logicalTypeName)}" object-id="${escapeHtml(ancestor.id)}" title="${escapeHtml(ancestor.title)}"></cw-object-link>
+ <cw-object-link logical-type="${escapeHtml(ancestor.logicalTypeName)}" object-id="${escapeHtml(ancestor.id)}" title="${escapeHtml(ancestor.title)}"${ancestor.icon ? ` icon="${escapeHtml(ancestor.icon)}"` : ''}></cw-object-link>
</li>`).join('\n ');
this.innerHTML = `<nav class="causeway-breadcrumbs" aria-label="Breadcrumb" aria-busy="false">
<ol class="causeway-breadcrumbs-list">
diff --git a/viewers/webcomponents/foundation/src/collection-element.mjs b/viewers/webcomponents/foundation/src/collection-element.mjs
index 1fb4c128f07..dd75f14c4b3 100644
--- a/viewers/webcomponents/foundation/src/collection-element.mjs
+++ b/viewers/webcomponents/foundation/src/collection-element.mjs
@@ -758,7 +758,7 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
#renderDefaultRows(rows) {
return `<ul class="causeway-collection-rows">${rows.map(row => {
const metadata = row?._meta ?? {};
- return `<li><cw-object-link logical-type="${escapeHtml(metadata.logicalTypeName ?? '')}" object-id="${escapeHtml(metadata.id ?? '')}" title="${escapeHtml(metadata.title ?? metadata.id ?? '')}"></cw-object-link></li>`;
+ return `<li><cw-object-link logical-type="${escapeHtml(metadata.logicalTypeName ?? '')}" object-id="${escapeHtml(metadata.id ?? '')}" title="${escapeHtml(metadata.title ?? metadata.id ?? '')}"${metadata.icon ? ` icon="${escapeHtml(metadata.icon)}"` : ''}></cw-object-link></li>`;
}).join('')}</ul>`;
}
@@ -767,7 +767,7 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
const body = rows.map((row, rowIndex) => {
const metadata = row?._meta ?? {};
const key = gridRowKey(row);
- const item = `<td data-causeway-grid-row-key="${escapeHtml(key)}" data-causeway-grid-member="_meta" data-causeway-grid-role="object-link"><cw-object-link logical-type="${escapeHtml(metadata.logicalTypeName ?? '')}" object-id="${escapeHtml(metadata.id ?? '')}" title="${escapeHtml(metadata.title ?? metadata.id ?? '')}"></cw-object-link></td>`;
+ const item = `<td data-causeway-grid-row-key="${escapeHtml(key)}" data-causeway-grid-member="_meta" data-causeway-grid-role="object-link"><cw-object-link logical-type="${escapeHtml(metadata.logicalTypeName ?? '')}" object-id="${escapeHtml(metadata.id ?? '')}" title="${escapeHtml(metadata.title ?? metadata.id ?? '')}"${metadata.icon ? ` icon="${escapeHtml(metadata.icon)}"` : ''}></cw-object-link></td>`;
const cells = this._columns.map(column => this.#renderCell(row, rowIndex, column)).join('');
return `<tr data-row-index="${rowIndex}" data-causeway-grid-row-key="${escapeHtml(key)}">${item}${cells}</tr>`;
}).join('');
diff --git a/viewers/webcomponents/foundation/src/collection-grid-projection.mjs b/viewers/webcomponents/foundation/src/collection-grid-projection.mjs
index ff345d5fe52..5f42190b310 100644
--- a/viewers/webcomponents/foundation/src/collection-grid-projection.mjs
+++ b/viewers/webcomponents/foundation/src/collection-grid-projection.mjs
@@ -100,7 +100,8 @@ function projectRow({row, rowIndex, columns, rowDescription, errors, rendererReg
const identity = Object.freeze({
logicalTypeName: String(metadata.logicalTypeName),
id: String(metadata.id),
- title: String(metadata.title ?? metadata.id)
+ title: String(metadata.title ?? metadata.id),
+ icon: metadata.icon ? String(metadata.icon) : ''
});
const cells = [Object.freeze({
member: '_meta',
@@ -111,7 +112,7 @@ function projectRow({row, rowIndex, columns, rowDescription, errors, rendererReg
rendererId: 'object-link',
standard: true,
kind: 'reference',
- html: `<cw-object-link logical-type="${escapeHtml(identity.logicalTypeName)}" object-id="${escapeHtml(identity.id)}" title="${escapeHtml(identity.title)}"></cw-object-link>`
+ html: `<cw-object-link logical-type="${escapeHtml(identity.logicalTypeName)}" object-id="${escapeHtml(identity.id)}" title="${escapeHtml(identity.title)}"${identity.icon ? ` icon="${escapeHtml(identity.icon)}"` : ''}></cw-object-link>`
})];
for (const column of columns) {
const property = row?.[column.member] ?? null;
diff --git a/viewers/webcomponents/foundation/src/component-styles.css b/viewers/webcomponents/foundation/src/component-styles.css
index ac14997be46..604618d75c9 100644
--- a/viewers/webcomponents/foundation/src/component-styles.css
+++ b/viewers/webcomponents/foundation/src/component-styles.css
@@ -508,7 +508,8 @@ cw-action-control > vaadin-button > .causeway-action-icon:last-child {
.causeway-breadcrumbs-current {
font-weight: 600;
}
-.causeway-breadcrumbs .causeway-object-link-identity {
+.causeway-breadcrumbs .causeway-object-link-identity,
+.causeway-object-header-link .causeway-object-link-identity {
display: none;
}
.causeway-object-link {
@@ -522,6 +523,13 @@ cw-action-control > vaadin-button > .causeway-action-icon:last-child {
padding: 0;
text-decoration: underline;
}
+.causeway-object-link-icon {
+ align-self: center;
+ block-size: var(--causeway-object-icon-size, 1.5rem);
+ flex: 0 0 var(--causeway-object-icon-size, 1.5rem);
+ inline-size: var(--causeway-object-icon-size, 1.5rem);
+ object-fit: var(--causeway-object-icon-fit, contain);
+}
.causeway-object-link-identity {
font-size: 0.8em;
}
diff --git a/viewers/webcomponents/foundation/src/component-styles.mjs b/viewers/webcomponents/foundation/src/component-styles.mjs
index b9e64f5dc2c..1390effef89 100644
--- a/viewers/webcomponents/foundation/src/component-styles.mjs
+++ b/viewers/webcomponents/foundation/src/component-styles.mjs
@@ -509,7 +509,8 @@ cw-action-control > vaadin-button > .causeway-action-icon:last-child {
.causeway-breadcrumbs-current {
font-weight: 600;
}
-.causeway-breadcrumbs .causeway-object-link-identity {
+.causeway-breadcrumbs .causeway-object-link-identity,
+.causeway-object-header-link .causeway-object-link-identity {
display: none;
}
.causeway-object-link {
@@ -523,6 +524,13 @@ cw-action-control > vaadin-button > .causeway-action-icon:last-child {
padding: 0;
text-decoration: underline;
}
+.causeway-object-link-icon {
+ align-self: center;
+ block-size: var(--causeway-object-icon-size, 1.5rem);
+ flex: 0 0 var(--causeway-object-icon-size, 1.5rem);
+ inline-size: var(--causeway-object-icon-size, 1.5rem);
+ object-fit: var(--causeway-object-icon-fit, contain);
+}
.causeway-object-link-identity {
font-size: 0.8em;
}
diff --git a/viewers/webcomponents/foundation/src/interaction-operations.mjs b/viewers/webcomponents/foundation/src/interaction-operations.mjs
index fe60f591d4d..daab152841e 100644
--- a/viewers/webcomponents/foundation/src/interaction-operations.mjs
+++ b/viewers/webcomponents/foundation/src/interaction-operations.mjs
@@ -248,7 +248,7 @@ export function metadataSelectionForType(typeRef, types) {
const metadataType = metadataField ? types.get(namedType(metadataField.type)) ?? null : null;
const metadataFields = fieldsByName(metadataType);
const selection = Object.fromEntries(
- ['id', 'logicalTypeName', 'title', 'version']
+ ['id', 'logicalTypeName', 'title', 'version', 'icon']
.filter(fieldName => metadataFields.has(fieldName))
.map(fieldName => [fieldName, true]));
return Object.keys(selection).length > 0 ? {_meta: selection} : null;
diff --git a/viewers/webcomponents/foundation/src/object-context-controller.mjs b/viewers/webcomponents/foundation/src/object-context-controller.mjs
index 13a26a3d877..e647c970321 100644
--- a/viewers/webcomponents/foundation/src/object-context-controller.mjs
+++ b/viewers/webcomponents/foundation/src/object-context-controller.mjs
@@ -1270,6 +1270,10 @@ function translateRequirement(requirement, description) {
if (!breadcrumbs || requiredFields.some(field => !breadcrumbFields.has(field))) {
throw new Error(`Metadata type '${metadata.generatedTypeName}' lacks navigable breadcrumb fields.`);
}
+ const breadcrumbFieldsToSelect = [
+ ...requiredFields,
+ ...(['icon'].filter(field => breadcrumbFields.has(field)))
+ ];
const currentFields = ['id', 'logicalTypeName', 'title'];
if (currentFields.some(field => !metadata.fields.has(field))) {
throw new Error(`Metadata type '${metadata.generatedTypeName}' lacks object identity fields.`);
@@ -1278,12 +1282,12 @@ function translateRequirement(requirement, description) {
descriptor: metadata,
selection: {[metadata.id]: {
...Object.fromEntries(currentFields.map(field => [field, true])),
- breadcrumbs: Object.fromEntries(requiredFields.map(field => [field, true]))
+ breadcrumbs: Object.fromEntries(breadcrumbFieldsToSelect.map(field => [field, true]))
}}
};
}
const requestedFields = requirement.kind === 'header'
- ? ['id', 'logicalTypeName', 'title', 'version']
+ ? ['id', 'logicalTypeName', 'title', 'version', 'icon']
: ['grid', 'layout', 'cssClass'];
const supportedFields = requestedFields.filter(field => metadata.fields.has(field));
if (requirement.kind === 'header'
diff --git a/viewers/webcomponents/foundation/src/object-header-element.mjs b/viewers/webcomponents/foundation/src/object-header-element.mjs
index 45829d1d94d..86a09296cd9 100644
--- a/viewers/webcomponents/foundation/src/object-header-element.mjs
+++ b/viewers/webcomponents/foundation/src/object-header-element.mjs
@@ -35,10 +35,13 @@ export class CausewayObjectHeaderElement extends CausewayContextConsumerElement
return;
}
const metadata = state.data ?? {};
- const title = metadata.title || `${metadata.logicalTypeName ?? ''}:${metadata.id ?? ''}`;
+ const logicalTypeName = metadata.logicalTypeName ?? '';
+ const id = metadata.id ?? '';
+ const title = metadata.title || `${logicalTypeName}:${id}`;
+ const icon = metadata.icon ? ` icon="${escapeHtml(metadata.icon)}"` : '';
this.innerHTML = `<header class="causeway-object-header" aria-busy="false">
- <h1>${escapeHtml(title)}</h1>
- <p class="causeway-object-identity" data-logical-type="${escapeHtml(metadata.logicalTypeName ?? '')}" data-object-id="${escapeHtml(metadata.id ?? '')}">${escapeHtml(metadata.logicalTypeName ?? '')}:${escapeHtml(metadata.id ?? '')}</p>
+ <h1><cw-object-link class="causeway-object-header-link" logical-type="${escapeHtml(logicalTypeName)}" object-id="${escapeHtml(id)}" title="${escapeHtml(title)}"${icon}></cw-object-link></h1>
+ <p class="causeway-object-identity" data-logical-type="${escapeHtml(logicalTypeName)}" data-object-id="${escapeHtml(id)}">${escapeHtml(logicalTypeName)}:${escapeHtml(id)}</p>
</header>`;
}
}
diff --git a/viewers/webcomponents/foundation/src/object-link-element.mjs b/viewers/webcomponents/foundation/src/object-link-element.mjs
index 7961ffe279c..982dea59461 100644
--- a/viewers/webcomponents/foundation/src/object-link-element.mjs
+++ b/viewers/webcomponents/foundation/src/object-link-element.mjs
@@ -25,7 +25,7 @@ const HTMLElementBase = globalThis.HTMLElement ?? class extends EventTarget {};
export class CausewayObjectLinkElement extends HTMLElementBase {
static get observedAttributes() {
- return ['logical-type', 'object-id', 'title', 'disabled'];
+ return ['logical-type', 'object-id', 'title', 'icon', 'disabled'];
}
constructor() {
@@ -51,6 +51,18 @@ export class CausewayObjectLinkElement extends HTMLElementBase {
this.setAttribute('title', value?.title ?? value?.id ?? '');
}
+ get icon() {
+ return this.getAttribute('icon') || '';
+ }
+
+ set icon(value) {
+ if (value == null || String(value).trim() === '') {
+ this.removeAttribute('icon');
+ } else {
+ this.setAttribute('icon', String(value));
+ }
+ }
+
get disabled() {
return this.hasAttribute('disabled');
}
@@ -88,9 +100,16 @@ export class CausewayObjectLinkElement extends HTMLElementBase {
render() {
const target = this.target;
const disabled = this.disabled || !target.logicalTypeName || !target.id;
+ const icon = this.icon;
this.innerHTML = `<button type="button" class="causeway-object-link" role="link"${disabled ? ' disabled aria-disabled="true"' : ''}>
+ ${icon ? `<img class="causeway-object-link-icon" src="${escapeHtml(icon)}" alt="" aria-hidden="true">` : ''}
<span class="causeway-object-link-title">${escapeHtml(target.title)}</span>
<span class="causeway-object-link-identity">${escapeHtml(target.logicalTypeName)}:${escapeHtml(target.id)}</span>
</button>`;
+ const image = this.querySelector?.('.causeway-object-link-icon');
+ image?.addEventListener?.('error', () => {
+ image.hidden = true;
+ image.setAttribute('aria-hidden', 'true');
+ }, {once: true});
}
}
diff --git a/viewers/webcomponents/foundation/src/value-renderers.mjs b/viewers/webcomponents/foundation/src/value-renderers.mjs
index 45ca8d082d0..b5661c0af25 100644
--- a/viewers/webcomponents/foundation/src/value-renderers.mjs
+++ b/viewers/webcomponents/foundation/src/value-renderers.mjs
@@ -146,7 +146,8 @@ function innermostType(typeRef) {
}
function objectLinkMarkup(metadata) {
- return `<cw-object-link class="causeway-value causeway-value-object-reference" logical-type="${escapeHtml(metadata.logicalTypeName)}" object-id="${escapeHtml(metadata.id)}" title="${escapeHtml(metadata.title ?? metadata.id)}"></cw-object-link>`;
+ const icon = metadata.icon ? ` icon="${escapeHtml(metadata.icon)}"` : '';
+ return `<cw-object-link class="causeway-value causeway-value-object-reference" logical-type="${escapeHtml(metadata.logicalTypeName)}" object-id="${escapeHtml(metadata.id)}" title="${escapeHtml(metadata.title ?? metadata.id)}"${icon}></cw-object-link>`;
}
function isLob(value, contentField) {
diff --git a/viewers/webcomponents/foundation/test/collection.test.mjs b/viewers/webcomponents/foundation/test/collection.test.mjs
index e7df6e6a122..2eab8898b5e 100644
--- a/viewers/webcomponents/foundation/test/collection.test.mjs
+++ b/viewers/webcomponents/foundation/test/collection.test.mjs
@@ -48,7 +48,13 @@ const {
function row({id = 'staff-1', name = 'Dr Ada', code = 'ADA', version = '3'} = {}) {
return {
- _meta: {id, logicalTypeName: STAFF_LOGICAL_TYPE, ...(version == null ? {} : {version}), title: name},
+ _meta: {
+ id,
+ logicalTypeName: STAFF_LOGICAL_TYPE,
+ ...(version == null ? {} : {version}),
+ title: name,
+ icon: `/graphql/object/${STAFF_LOGICAL_TYPE}:${id}/_meta/icon`
+ },
name: {
hidden: false,
disabled: null,
@@ -993,6 +999,10 @@ test('collection host owns immutable Grid qualification diagnostics policy recov
assert.equal(gridAdapter.presentation.mode, 'virtual');
assert.deepEqual(gridAdapter.presentation.columns.map(column => column.member), ['_meta', 'name']);
assert.equal(gridAdapter.presentation.rows[0].identity.id, 'staff-1');
+ assert.equal(
+ gridAdapter.presentation.rows[0].identity.icon,
+ '/graphql/object/university.staff.StaffMember:staff-1/_meta/icon');
+ assert.match(gridAdapter.presentation.rows[0].cells[0].html, / icon="\/graphql\/object\/university\.staff\.StaffMember:staff-1\/_meta\/icon"/);
assert.equal(gridAdapter.presentation.totalCount, 40);
assert.equal(gridAdapter.presentation.resizableColumns, false);
assert.equal(gridAdapter.presentation.reorderableColumns, false);
@@ -1121,6 +1131,7 @@ test('collection component renders default object links, empty and partial-error
collection.collectionState = {status: 'ready', data: {get: [row()]}, errors: []};
collection.renderComponentState(baseState);
assert.match(collection.innerHTML, /cw-object-link/);
+ assert.match(collection.innerHTML, /icon="\/graphql\/object\/university\.staff\.StaffMember:staff-1\/_meta\/icon"/);
collection.collectionState = {status: 'ready', data: {get: []}, errors: []};
collection.renderComponentState(baseState);
diff --git a/viewers/webcomponents/foundation/test/component-styles.test.mjs b/viewers/webcomponents/foundation/test/component-styles.test.mjs
index 2cbca1ba4da..9baedb7751f 100644
--- a/viewers/webcomponents/foundation/test/component-styles.test.mjs
+++ b/viewers/webcomponents/foundation/test/component-styles.test.mjs
@@ -50,6 +50,11 @@ test('property editor actions remain compact deterministic icon controls', async
assert.match(theme, /\.causeway-property-editor-actions button:first-child[\s\S]*?background: var\(--causeway-action-background\)/);
});
+test('object-link icons remain bounded, decorative markup is themeable and header identity is not duplicated', () => {
+ assert.match(CAUSEWAY_COMPONENT_STYLES, /\.causeway-object-link-icon \{[\s\S]*?block-size: var\(--causeway-object-icon-size, 1\.5rem\);[\s\S]*?inline-size: var\(--causeway-object-icon-size, 1\.5rem\);[\s\S]*?object-fit: var\(--causeway-object-icon-fit, contain\);/);
+ assert.match(CAUSEWAY_COMPONENT_STYLES, /\.causeway-breadcrumbs \.causeway-object-link-identity,\s+\.causeway-object-header-link \.causeway-object-link-identity \{\s+display: none;/);
+});
+
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/components.test.mjs b/viewers/webcomponents/foundation/test/components.test.mjs
index 471f9b6b2e7..b625cc020e5 100644
--- a/viewers/webcomponents/foundation/test/components.test.mjs
+++ b/viewers/webcomponents/foundation/test/components.test.mjs
@@ -29,6 +29,7 @@ const {
CausewayCollectionColumnElement,
CausewayCollectionElement,
CausewayObjectContextElement,
+ CausewayObjectHeaderElement,
CausewayParameterElement,
CausewayPropertyElement,
CausewaySemanticEvent,
@@ -71,6 +72,26 @@ test('action parameter configuration normalizes optional presentation hints', ()
});
});
+test('object header renders an icon-bearing semantic self-link in one heading', () => {
+ const header = new CausewayObjectHeaderElement();
+ header.renderComponentState(state({
+ data: {
+ id: 'owner-1',
+ logicalTypeName: 'petclinic.PetOwner',
+ title: 'Mary & family',
+ icon: '/graphql/object/petclinic.PetOwner:owner-1/_meta/icon'
+ }
+ }));
+
+ assert.match(header.innerHTML, /<h1><cw-object-link class="causeway-object-header-link"/);
+ assert.match(header.innerHTML, /logical-type="petclinic\.PetOwner"/);
+ assert.match(header.innerHTML, /object-id="owner-1"/);
+ assert.match(header.innerHTML, /title="Mary & family"/);
+ assert.match(header.innerHTML, /icon="\/graphql\/object\/petclinic\.PetOwner:owner-1\/_meta\/icon"/);
+ assert.equal((header.innerHTML.match(/<h1>/g) ?? []).length, 1);
+ assert.match(header.innerHTML, /class="causeway-object-identity"/);
+});
+
test('breadcrumbs render accessible ancestor links, escaped current state and local errors', () => {
const breadcrumbs = new CausewayBreadcrumbsElement();
breadcrumbs.renderComponentState(state({
@@ -79,7 +100,12 @@ test('breadcrumbs render accessible ancestor links, escaped current state and lo
logicalTypeName: 'petclinic.Visit',
title: 'Checkup <today>',
breadcrumbs: [
- {logicalTypeName: 'petclinic.PetOwner', id: 'owner-1', title: 'Mary & family'},
+ {
+ logicalTypeName: 'petclinic.PetOwner',
+ id: 'owner-1',
+ title: 'Mary & family',
+ icon: '/graphql/object/petclinic.PetOwner:owner-1/_meta/icon'
+ },
{logicalTypeName: 'petclinic.Pet', id: 'pet-1', title: 'Basil'},
{logicalTypeName: '', id: 'invalid', title: 'Malformed'}
]
@@ -92,6 +118,7 @@ test('breadcrumbs render accessible ancestor links, escaped current state and lo
assert.match(breadcrumbs.innerHTML, /logical-type="petclinic\.PetOwner"/);
assert.match(breadcrumbs.innerHTML, /object-id="owner-1"/);
assert.match(breadcrumbs.innerHTML, /Mary & family/);
+ assert.match(breadcrumbs.innerHTML, /icon="\/graphql\/object\/petclinic\.PetOwner:owner-1\/_meta\/icon"/);
assert.match(breadcrumbs.innerHTML, /Basil/);
assert.match(breadcrumbs.innerHTML, /aria-current="page">Checkup <today>/);
assert.doesNotMatch(breadcrumbs.innerHTML, /Malformed/);
diff --git a/viewers/webcomponents/foundation/test/domain-components.test.mjs b/viewers/webcomponents/foundation/test/domain-components.test.mjs
index 92f1aa50891..8bde84df1d6 100644
--- a/viewers/webcomponents/foundation/test/domain-components.test.mjs
+++ b/viewers/webcomponents/foundation/test/domain-components.test.mjs
@@ -81,12 +81,15 @@ test('registers the complete compact custom-element vocabulary without old alias
test('object links publish cancelable bubbling and composed semantic navigation', () => {
const link = new CausewayObjectLinkElement();
link.target = {logicalTypeName: 'university.staff.StaffMember', id: 'staff-1', title: 'Dr Ada'};
+ link.icon = '/graphql/object/university.staff.StaffMember:staff-1/_meta/icon';
document.body.appendChild(link);
let received;
document.body.addEventListener(NAVIGATION_REQUEST_EVENT, event => {
received = event;
});
assert.match(link.innerHTML, /<button type="button"[^>]*role="link"/);
+ assert.match(link.innerHTML, /<img class="causeway-object-link-icon"[^>]*alt=""[^>]*aria-hidden="true"/);
+ assert.match(link.innerHTML, /src="\/graphql\/object\/university\.staff\.StaffMember:staff-1\/_meta\/icon"/);
assert.equal(link.activate(), true);
assert.equal(received.bubbles, true);
assert.equal(received.composed, true);
@@ -95,10 +98,26 @@ test('object links publish cancelable bubbling and composed semantic navigation'
id: 'staff-1',
title: 'Dr Ada'
});
+ link.icon = null;
+ assert.doesNotMatch(link.innerHTML, /causeway-object-link-icon/);
link.disabled = true;
assert.equal(link.activate(), false);
});
+test('object links hide failed decorative icons without changing navigation', () => {
+ const failedImage = document.createElement('img');
+ const link = new CausewayObjectLinkElement();
+ link.target = {logicalTypeName: 'petclinic.Pet', id: 'pet-1', title: 'Basil'};
+ link.icon = '/missing/icon';
+ link.querySelector = selector => selector === '.causeway-object-link-icon' ? failedImage : null;
+ link.render();
+
+ failedImage.dispatchEvent(new Event('error'));
+ assert.equal(failedImage.hidden, true);
+ assert.equal(failedImage.getAttribute('aria-hidden'), 'true');
+ assert.equal(link.activate(), true);
+});
+
test('actions render semantic states and publish requests only while enabled', async () => {
let listener;
const context = {
diff --git a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
index 5c366c202e0..eaeb447e549 100644
--- a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
+++ b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
@@ -74,6 +74,7 @@ export function createRichSchemaTypes() {
field('logicalTypeName', null, scalar('String')),
field('version', null, scalar('String')),
field('title', null, scalar('String')),
+ field('icon', null, scalar('String')),
field('breadcrumbs', null, list(named(BREADCRUMB_TYPE))),
field('grid', null, scalar('String')),
field('layout', null, scalar('String')),
@@ -82,7 +83,8 @@ export function createRichSchemaTypes() {
[BREADCRUMB_TYPE, objectType(BREADCRUMB_TYPE, 'Navigable breadcrumb', [
field('logicalTypeName', null, scalar('String')),
field('id', null, scalar('ID')),
- field('title', null, scalar('String'))
+ field('title', null, scalar('String')),
+ field('icon', null, scalar('String'))
])],
[MEMBER_METADATA_TYPE, objectType(MEMBER_METADATA_TYPE, 'Member metadata', [
field('friendlyName', null, scalar('String')),
@@ -155,6 +157,7 @@ export function createRichSchemaTypes() {
field('logicalTypeName', null, scalar('String')),
field('version', null, scalar('String')),
field('title', null, scalar('String')),
+ field('icon', null, scalar('String')),
field('breadcrumbs', null, list(named(BREADCRUMB_TYPE)))
])],
[STAFF_NAME_TYPE, propertyType(STAFF_NAME_TYPE)],
@@ -314,6 +317,7 @@ export function departmentObjectData({
logicalTypeName: DEPARTMENT_LOGICAL_TYPE,
version,
title: 'Classics Department',
+ icon: '/graphql/object/university.dept.Department:42/_meta/icon',
breadcrumbs,
grid: '/graphql/object/university.dept.Department:42/_meta/grid',
layout: null,
@@ -338,7 +342,7 @@ export function departmentObjectData({
chair: {
hidden: false,
disabled: null,
- get: {_meta: {id: 'staff-1', logicalTypeName: 'university.staff.StaffMember', version: '3', title: 'Dr Ada'}}
+ get: {_meta: {id: 'staff-1', logicalTypeName: 'university.staff.StaffMember', version: '3', title: 'Dr Ada', icon: '/graphql/object/university.staff.StaffMember:staff-1/_meta/icon'}}
},
prospectus: {
hidden: false,
diff --git a/viewers/webcomponents/foundation/test/identity-selection.test.mjs b/viewers/webcomponents/foundation/test/identity-selection.test.mjs
index da89cb7ab26..a0137d7c921 100644
--- a/viewers/webcomponents/foundation/test/identity-selection.test.mjs
+++ b/viewers/webcomponents/foundation/test/identity-selection.test.mjs
@@ -36,14 +36,14 @@ function typesWithMetadata(metadataFields) {
]);
}
-test('metadata selection preserves all advertised versioned identity fields', () => {
- const types = typesWithMetadata(['id', 'logicalTypeName', 'title', 'version']);
+test('metadata selection preserves all advertised versioned identity and icon fields', () => {
+ const types = typesWithMetadata(['id', 'logicalTypeName', 'title', 'version', 'icon']);
assert.deepEqual(metadataSelectionForType(named('Example'), types), {
- _meta: {id: true, logicalTypeName: true, title: true, version: true}
+ _meta: {id: true, logicalTypeName: true, title: true, version: true, icon: true}
});
assert.deepEqual(resultSelectionForType(named('Example'), types), {
- _meta: {id: true, logicalTypeName: true, title: true, version: true}
+ _meta: {id: true, logicalTypeName: true, title: true, version: true, icon: true}
});
});
@@ -72,14 +72,14 @@ test('bounded described unions select concrete metadata through inline fragments
['Versioned', type('Versioned', [field('_meta', named('VersionedMeta'))])],
['VersionedMeta', type('VersionedMeta', ['id', 'logicalTypeName', 'version'].map(name => field(name, scalar('String'))))],
['Versionless', type('Versionless', [field('_meta', named('VersionlessMeta'))])],
- ['VersionlessMeta', type('VersionlessMeta', ['id', 'logicalTypeName', 'title'].map(name => field(name, scalar('String'))))]
+ ['VersionlessMeta', type('VersionlessMeta', ['id', 'logicalTypeName', 'title', 'icon'].map(name => field(name, scalar('String'))))]
]);
assert.deepEqual(resultSelectionForType(named('ExampleUnion', 'UNION'), types), {
__typename: true,
__fragments: {
Versioned: {_meta: {id: true, logicalTypeName: true, version: true}},
- Versionless: {_meta: {id: true, logicalTypeName: true, title: true}}
+ Versionless: {_meta: {id: true, logicalTypeName: true, title: true, icon: true}}
}
});
});
diff --git a/viewers/webcomponents/foundation/test/object-context.test.mjs b/viewers/webcomponents/foundation/test/object-context.test.mjs
index 64cc4192156..15b11d70750 100644
--- a/viewers/webcomponents/foundation/test/object-context.test.mjs
+++ b/viewers/webcomponents/foundation/test/object-context.test.mjs
@@ -44,6 +44,7 @@ test('coalesces header and property requirements into one initial object read',
await waitFor(() => context.state.status === 'ready');
assert.equal(executor.readCalls.length, 1);
assert.match(executor.readCalls[0].document, /_meta \{/);
+ assert.match(executor.readCalls[0].document, /\bicon\b/);
assert.match(executor.readCalls[0].document, /name \{/);
assert.match(executor.readCalls[0].document, /metadata\s*\{/);
for (const field of ['friendlyName', 'description', 'multiLine', 'labelPosition']) {
@@ -63,7 +64,12 @@ test('coalesces header and property requirements into one initial object read',
test('coalesces semantic breadcrumbs and isolates breadcrumb metadata errors', async () => {
const breadcrumbs = [
- {logicalTypeName: 'university.University', id: 'u-1', title: 'Example University'},
+ {
+ logicalTypeName: 'university.University',
+ id: 'u-1',
+ title: 'Example University',
+ icon: '/graphql/object/university.University:u-1/_meta/icon'
+ },
{logicalTypeName: 'university.Faculty', id: 'f-1', title: 'Arts Faculty'}
];
const executor = createRichSchemaFixtureExecutor({
@@ -76,6 +82,7 @@ test('coalesces semantic breadcrumbs and isolates breadcrumb metadata errors', a
await waitFor(() => context.state.status === 'ready');
assert.equal(executor.readCalls.length, 1);
+ assert.match(executor.readCalls[0].document, /breadcrumbs\s*\{[\s\S]*\bicon\b/);
assert.match(executor.readCalls[0].document, /breadcrumbs\s*\{[\s\S]*logicalTypeName[\s\S]*id[\s\S]*title/);
assert.deepEqual(breadcrumbState.data.breadcrumbs, breadcrumbs);
assert.equal(breadcrumbState.data.title, 'Classics Department');
@@ -101,6 +108,24 @@ test('coalesces semantic breadcrumbs and isolates breadcrumb metadata errors', a
assert.equal(successfulHeader.data.title, 'Classics Department');
});
+test('omits icon selections when object and breadcrumb metadata do not advertise them', async () => {
+ const types = createRichSchemaTypes();
+ for (const [name, type] of types) {
+ types.set(name, {...type, fields: type.fields.filter(field => field.name !== 'icon')});
+ }
+ const executor = createRichSchemaFixtureExecutor({types});
+ const context = createContext(executor);
+ let headerState;
+ let breadcrumbState;
+ context.registerRequirement({kind: 'header'}, state => { headerState = state; });
+ context.registerRequirement({kind: 'breadcrumbs'}, state => { breadcrumbState = state; });
+ await waitFor(() => context.state.status === 'ready');
+
+ assert.equal(headerState.status, 'ready');
+ assert.equal(breadcrumbState.status, 'ready');
+ assert.doesNotMatch(executor.readCalls[0].document, /\bicon\b/);
+});
+
test('reports breadcrumbs unsupported when metadata lacks the shared entry field', async () => {
const types = createRichSchemaTypes();
for (const [name, type] of types) {
@@ -297,9 +322,13 @@ test('object-valued property projection omits an absent metadata version', async
assert.match(chairSelection, /_meta\s*\{[\s\S]*?id/);
assert.match(chairSelection, /logicalTypeName/);
assert.match(chairSelection, /title/);
+ assert.match(chairSelection, /icon/);
assert.doesNotMatch(chairSelection, /version/);
assert.deepEqual(chairState.data.get._meta, {
- id: 'staff-1', logicalTypeName: 'university.staff.StaffMember', title: 'Dr Ada'
+ id: 'staff-1',
+ logicalTypeName: 'university.staff.StaffMember',
+ title: 'Dr Ada',
+ icon: '/graphql/object/university.staff.StaffMember:staff-1/_meta/icon'
});
});
diff --git a/viewers/webcomponents/foundation/test/value-renderers.test.mjs b/viewers/webcomponents/foundation/test/value-renderers.test.mjs
index 9a0fa0a6753..6c80830a8e6 100644
--- a/viewers/webcomponents/foundation/test/value-renderers.test.mjs
+++ b/viewers/webcomponents/foundation/test/value-renderers.test.mjs
@@ -46,12 +46,18 @@ test('standard value renderers cover scalars, enums, nulls and object references
assert.match(nullResult.html, /No value/);
const objectResult = renderCausewayValue({
- value: {_meta: {id: 'staff-1', logicalTypeName: 'university.staff.StaffMember', title: 'Dr Ada'}},
+ value: {_meta: {
+ id: 'staff-1',
+ logicalTypeName: 'university.staff.StaffMember',
+ title: 'Dr Ada',
+ icon: '/graphql/object/university.staff.StaffMember:staff-1/_meta/icon'
+ }},
descriptor: descriptor(object)
});
assert.equal(objectResult.rendererId, 'object-reference');
assert.match(objectResult.html, /cw-object-link/);
assert.match(objectResult.html, /staff-1/);
+ assert.match(objectResult.html, /icon="\/graphql\/object\/university\.staff\.StaffMember:staff-1\/_meta\/icon"/);
});
test('standard value renderers cover Blob and Clob resource representations', () => {
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 1c89ddaa4e4..21064752ade 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
@@ -202,6 +202,7 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
assertFocused(ROUTE_PAGE);
waitForCollectionRows("petOwners", 2);
waitForCollectionRows("futureVisits", 3);
+ assertObjectLinkIcon(page.locator("cw-collection[id='petOwners'] cw-object-link").first());
assertThat(page.locator("cw-collection[id='petOwners']").getAttribute("paged")).isEqualTo("2");
assertThat(page.locator("cw-collection[id='petOwners'] [data-causeway-grid-next]").isVisible()).isTrue();
assertThat(page.locator("cw-collection[id='futureVisits'] .causeway-collection-label").innerText())
@@ -250,6 +251,8 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
assertThat(page.locator(ROUTE_PAGE).getAttribute("data-page-kind")).isEqualTo("custom");
assertThat(page.locator(ROUTE_PAGE).getAttribute("data-page-source")).isEqualTo("resource");
assertThat(page.locator("[data-testid='petclinic-owner-page']").isVisible()).isTrue();
+ waitForObjectTitle("Mary Smith (Mary)");
+ assertObjectLinkIcon(page.locator("cw-object-header cw-object-link"));
waitForNoBreadcrumbs();
assertThat(page.locator("[data-testid='petclinic-breadcrumbs'] nav").count()).isZero();
assertThat(page.locator(".petclinic-object-grid").evaluate("""
@@ -262,6 +265,7 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
assertFocused(ROUTE_PAGE);
waitForCollectionRows("pets", 2);
waitForCollectionRows("visits", 1);
+ assertObjectLinkIcon(page.locator("cw-collection[id='pets'] cw-object-link").first());
assertThat(page.locator("cw-collection[id='pets']").getAttribute("sortable")).isEmpty();
assertThat(page.locator("cw-collection[id='pets']").getAttribute("filterable")).isEmpty();
final var petSearch = page.locator("cw-collection[id='pets'] [data-causeway-collection-search]");
@@ -353,17 +357,31 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
assertThat(page.locator("[data-testid='petclinic-pet-page']").isVisible()).isTrue();
waitForObjectTitle("Basil · dog");
waitForBreadcrumbs(1);
+ assertObjectLinkIcon(page.locator("cw-object-header cw-object-link"));
+ assertObjectLinkIcon(page.locator("cw-property[id='petOwner'] cw-object-link"));
+ assertObjectLinkIcon(page.locator("[data-testid='petclinic-breadcrumbs'] cw-object-link"));
assertThat(page.locator("[data-testid='petclinic-breadcrumbs'] cw-object-link").getAttribute("title"))
.isEqualTo("Mary Smith (Mary)");
assertThat(page.locator("[data-testid='petclinic-breadcrumbs'] [aria-current='page']").textContent())
.isEqualTo("Basil · dog");
waitForMenus();
+ final var petUrl = page.url();
+ page.locator(ROUTE_PAGE).evaluate("element => { element.dataset.selfLinkProbe = 'old'; }");
+ page.locator("cw-object-header cw-object-link button").click();
+ page.waitForFunction("() => document.querySelector('[data-testid=\"causeway-route-page\"]')?.dataset.selfLinkProbe !== 'old'");
+ waitForRoute("petclinic.Pet", "s_pet-basil");
+ waitForObjectTitle("Basil · dog");
+ assertThat(page.url()).isEqualTo(petUrl);
+ assertObjectLinkIcon(page.locator("cw-object-header cw-object-link"));
+
page.navigate(url("/htmx/object/petclinic.Visit/s_visit-basil-checkup"),
new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
waitForRoute("petclinic.Visit", "s_visit-basil-checkup");
assertThat(page.locator("[data-testid='petclinic-visit-page']").isVisible()).isTrue();
waitForBreadcrumbs(2);
+ assertThat(page.locator("[data-testid='petclinic-breadcrumbs'] cw-object-link .causeway-object-link-icon").count())
+ .isEqualTo(2);
assertThat(page.locator("[data-testid='petclinic-breadcrumbs'] cw-object-link")
.evaluateAll("elements => elements.map(element => element.getAttribute('title')).join(',')"))
.isEqualTo("Mary Smith (Mary),Basil · dog");
@@ -987,6 +1005,18 @@ private void clickObjectLink(final Locator links, final String titlePart) {
link.click();
}
+ private void assertObjectLinkIcon(final Locator objectLink) {
+ objectLink.waitFor();
+ final var icon = objectLink.locator(".causeway-object-link-icon");
+ assertThat(icon.count()).isEqualTo(1);
+ assertThat(icon.getAttribute("alt")).isEmpty();
+ assertThat(icon.getAttribute("aria-hidden")).isEqualTo("true");
+ assertThat(icon.getAttribute("src")).contains("/_meta/icon");
+ assertThat((Boolean) icon.evaluate(
+ "image => image.decode().then(() => image.complete && image.naturalWidth > 0).catch(() => false)"))
+ .isTrue();
+ }
+
private void openMenu(final String name) {
if (!nativeToolkit()) {
final var menuButton = page.locator("vaadin-menu-bar-button")