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/29 14:24:20 UTC
(causeway) 02/06: feat(webcomponents): enhance collection headings
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 da560ae67411ca10224b484643f9aa0c2b1554bf
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 15:53:26 2026 +0200
feat(webcomponents): enhance collection headings
---
.../changes/enhance-collection-headings/tasks.md | 26 +++----
viewers/webcomponents/foundation/USAGE.adoc | 12 +++-
.../foundation/src/collection-element.mjs | 54 ++++++++++-----
.../foundation/src/object-context-controller.mjs | 8 ++-
viewers/webcomponents/foundation/src/theme.css | 10 ++-
.../foundation/test/collection.test.mjs | 81 ++++++++++++++++++++++
.../foundation/test/component-styles.test.mjs | 6 ++
.../test/fixtures/rich-schema-fixture.mjs | 14 +++-
.../foundation/test/member-composition.test.mjs | 4 +-
.../foundation/test/object-context.test.mjs | 50 +++++++++++++
.../htmx/petclinic/domain/PetClinicHomePage.java | 4 +-
.../sample/htmx/petclinic/domain/PetOwner.java | 4 +-
.../webcomponents/pages/petclinic.HomePage.html | 2 +-
.../webcomponents/pages/petclinic.PetOwner.html | 6 +-
.../petclinic/PetClinicHtmxPlaywrightTest.java | 23 ++++++
.../PetClinicHtmxApplication_IntegTest.java | 23 ++++--
16 files changed, 280 insertions(+), 47 deletions(-)
diff --git a/openspec/changes/enhance-collection-headings/tasks.md b/openspec/changes/enhance-collection-headings/tasks.md
index 3f0bb0b01d8..cdc15b13075 100644
--- a/openspec/changes/enhance-collection-headings/tasks.md
+++ b/openspec/changes/enhance-collection-headings/tasks.md
@@ -1,24 +1,24 @@
## 1. Collection metadata and heading behavior
-- [ ] 1.1 Extend targeted collection requirements to select supported canonical friendly-name and description metadata.
-- [ ] 1.2 Implement `named` and `described-as` collection attributes with documented precedence, compatibility, duplicate suppression, and reactive rerendering.
-- [ ] 1.3 Remove visible collection-level disabled or unmodifiable reason markup while preserving read-only and member/action semantics.
-- [ ] 1.4 Refine collection description styling below the label with accessible subdued typography.
+- [x] 1.1 Extend targeted collection requirements to select supported canonical friendly-name and description metadata.
+- [x] 1.2 Implement `named` and `described-as` collection attributes with documented precedence, compatibility, duplicate suppression, and reactive rerendering.
+- [x] 1.3 Remove visible collection-level disabled or unmodifiable reason markup while preserving read-only and member/action semantics.
+- [x] 1.4 Refine collection description styling below the label with accessible subdued typography.
## 2. Foundation regression coverage
-- [ ] 2.1 Add object-context tests for targeted collection metadata selection, partial support, and request reuse.
-- [ ] 2.2 Add collection component tests for canonical and HTML heading precedence, reactive attributes, accessibility, duplicate suppression, and quiet unmodifiable state.
-- [ ] 2.3 Update stylesheet and component-contract coverage for the new public attributes and description presentation.
+- [x] 2.1 Add object-context tests for targeted collection metadata selection, partial support, and request reuse.
+- [x] 2.2 Add collection component tests for canonical and HTML heading precedence, reactive attributes, accessibility, duplicate suppression, and quiet unmodifiable state.
+- [x] 2.3 Update stylesheet and component-contract coverage for the new public attributes and description presentation.
## 3. Petclinic demonstrations
-- [ ] 3.1 Add selective `@CollectionLayout(describedAs)` descriptions to Petclinic collections.
-- [ ] 3.2 Add selective `named` and `described-as` overrides to Petclinic HTML pages while leaving other collections metadata-driven or unchanged.
-- [ ] 3.3 Extend Petclinic integration and Playwright assertions for names, descriptions, omitted unmodifiable noise, and unaffected interactions.
+- [x] 3.1 Add selective `@CollectionLayout(describedAs)` descriptions to Petclinic collections.
+- [x] 3.2 Add selective `named` and `described-as` overrides to Petclinic HTML pages while leaving other collections metadata-driven or unchanged.
+- [x] 3.3 Extend Petclinic integration and Playwright assertions for names, descriptions, omitted unmodifiable noise, and unaffected interactions.
## 4. Validation and archive readiness
-- [ ] 4.1 Run foundation tests and package verification.
-- [ ] 4.2 Run Petclinic integration and Playwright acceptance.
-- [ ] 4.3 Run strict OpenSpec validation, compilation, license, diff, and working-tree checks.
+- [x] 4.1 Run foundation tests and package verification.
+- [x] 4.2 Run Petclinic integration and Playwright acceptance.
+- [x] 4.3 Run strict OpenSpec validation, compilation, license, diff, and working-tree checks.
diff --git a/viewers/webcomponents/foundation/USAGE.adoc b/viewers/webcomponents/foundation/USAGE.adoc
index 5c1d7c08a25..6f218abfd08 100644
--- a/viewers/webcomponents/foundation/USAGE.adoc
+++ b/viewers/webcomponents/foundation/USAGE.adoc
@@ -57,12 +57,16 @@ The `cw-` element namespace replaces the former `causeway-` element namespace wi
Applications upgrading across this breaking change must replace each former opening and closing custom-element tag and each element-name selector with its one-to-one `cw-` equivalent.
Semantic `causeway-*` events, CSS classes, `data-causeway-*` attributes, `--causeway-*` variables, and `/causeway-*` asset paths keep their existing spelling.
-Properties, actions, collections, and collection columns use the standard `id` attribute and native reflected `id` property to identify their Causeway member, and accept an optional `label` override.
+Properties, actions, collections, and collection columns use the standard `id` attribute and native reflected `id` property to identify their Causeway member.
+Properties, actions, and collection columns accept an optional `label` override.
+Collections accept `named` and `described-as` heading overrides; the existing `label` name override remains compatible, with `named` taking precedence.
The former `member` attribute and custom `member` property are not compatibility aliases.
Applications must keep authored IDs unique according to ordinary HTML identifier rules.
A property exposes interaction only when its `editable` attribute or property is enabled.
Object links use `logical-type`, `object-id`, `title`, and `disabled`.
Collections use `active` and declarative collection-column children.
+Without HTML heading overrides, collections use canonical `metadata.friendlyName` and `metadata.description`, followed by a humanized member-id name and no-description fallback.
+Collection-level disabled or unmodifiable reasons are not presented as explanatory text or tooltips; individual cells and associated actions retain their own usability semantics.
The corresponding JavaScript properties support programmatic composition.
A property or collection accepts a `rendererRegistry`.
@@ -479,7 +483,7 @@ An authored property or collection can place object actions directly beneath the
<cw-action id="updateName"></cw-action>
</cw-property>
-<cw-collection id="pets" active>
+<cw-collection id="pets" named="Companion animals" described-as="Pets registered to this owner." active>
<cw-collection-column id="name" label="Name"></cw-collection-column>
<cw-collection-column id="species" label="Species"></cw-collection-column>
<cw-action id="addPet"></cw-action>
@@ -505,7 +509,9 @@ Generated `<cw-object>` layouts retain semantically equivalent associated-member
== Lazy collections and row hydration
-A collection's primary object requirement contains only advertised `hidden` and `disabled` state.
+A collection's primary object requirement contains advertised `hidden` and `disabled` state plus supported canonical `metadata.friendlyName` and `metadata.description` heading fields.
+The heading resolves `named`, legacy `label`, canonical friendly name, and humanized member id in that order; its description resolves `described-as`, canonical description, and no text in that order.
+A distinct description is associated with the collection and displayed below the heading in subdued smaller text, while a description equal to the resolved name is omitted.
The `get` field is loaded through a context-owned secondary operation after activation.
Secondary operations are cached by member and structural row selection, support forced refresh, and are canceled when their component or object context is disconnected.
diff --git a/viewers/webcomponents/foundation/src/collection-element.mjs b/viewers/webcomponents/foundation/src/collection-element.mjs
index b6f4f9f650b..416f59bba1f 100644
--- a/viewers/webcomponents/foundation/src/collection-element.mjs
+++ b/viewers/webcomponents/foundation/src/collection-element.mjs
@@ -69,7 +69,7 @@ export function captureDeclarativeCollectionColumns(root = globalThis.document)
export class CausewayCollectionElement extends CausewayContextConsumerElement {
static get observedAttributes() {
- return ['id', 'label', 'active'];
+ return ['id', 'named', 'described-as', 'label', 'active'];
}
constructor() {
@@ -156,6 +156,22 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
});
}
+ get named() {
+ return this.getAttribute('named') || '';
+ }
+
+ set named(value) {
+ this.setAttribute('named', value);
+ }
+
+ get describedAs() {
+ return this.getAttribute('described-as') || '';
+ }
+
+ set describedAs(value) {
+ this.setAttribute('described-as', value);
+ }
+
get label() {
return this.getAttribute('label') || '';
}
@@ -382,9 +398,9 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
return;
}
this.#qualifyGrid(state);
- const label = this.label || humanize(this.id);
- const candidateDescription = state.descriptor?.description || '';
- const description = candidateDescription.trim().toLocaleLowerCase() === label.trim().toLocaleLowerCase()
+ const label = firstNonBlank(this.named, this.label, state.data?.metadata?.friendlyName, humanize(this.id));
+ const candidateDescription = firstNonBlank(this.describedAs, state.data?.metadata?.description);
+ const description = normalizedText(candidateDescription) === normalizedText(label)
? ''
: candidateDescription;
const shell = (content, attributes = '') => collectionShell(
@@ -399,7 +415,11 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
renderMemberPrimary(this, shell('<span role="status">Loading collection metadata…</span>', ' aria-busy="true"'));
return;
}
- if (['terminal-error', 'unsupported', 'partial-error'].includes(state.status)) {
+ const recoverablePartialState = state.status === 'partial-error'
+ && state.data
+ && typeof state.data.hidden === 'boolean';
+ if (['terminal-error', 'unsupported'].includes(state.status)
+ || (state.status === 'partial-error' && !recoverablePartialState)) {
renderMemberPrimary(this, shell(`<span class="causeway-error" role="alert">${escapeHtml(errorMessage(state))}</span>`));
return;
}
@@ -407,14 +427,8 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
renderMemberPrimary(this, '', {hidden: true});
return;
}
- const disabledReason = typeof state.data?.disabled === 'string'
- ? state.data.disabled
- : state.data?.disabled === true ? 'Disabled' : '';
- const disabledMarkup = disabledReason
- ? `<p class="causeway-collection-disabled-reason">${escapeHtml(disabledReason)}</p>`
- : '';
if (!this.active) {
- renderMemberPrimary(this, shell(`${disabledMarkup}<button type="button" data-causeway-activate>Load ${escapeHtml(label)}</button>`));
+ renderMemberPrimary(this, shell(`<button type="button" data-causeway-activate>Load ${escapeHtml(label)}</button>`));
return;
}
if (this.collectionState.status === 'idle' || this.collectionState.status === 'loading') {
@@ -434,22 +448,22 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
? `<p class="causeway-error" role="alert">${escapeHtml(errorMessage(this.collectionState))}</p>`
: '';
if (this._gridQualification.qualified) {
- this.#renderGrid(shell, disabledMarkup, errors, description);
+ this.#renderGrid(shell, errors, description);
return;
}
const content = this._columns.length > 0
? this.#renderTable(rows)
: this.#renderDefaultRows(rows);
- renderMemberPrimary(this, shell(`${disabledMarkup}${content}${errors}`));
+ renderMemberPrimary(this, shell(`${content}${errors}`));
this.#restoreNativeFocus();
}
- #renderGrid(shell, disabledMarkup, errors, description) {
+ #renderGrid(shell, errors, description) {
const revision = this.gridHostRevision;
const bounded = this._gridQualification.presentation === 'grid-bounded';
const window = this.collectionState.window;
const pager = bounded ? this.#renderBoundedPager(window) : '';
- renderMemberPrimary(this, shell(`${disabledMarkup}<${CAUSEWAY_COLLECTION_GRID} data-causeway-collection-grid></${CAUSEWAY_COLLECTION_GRID}>${pager}${errors}`));
+ renderMemberPrimary(this, shell(`<${CAUSEWAY_COLLECTION_GRID} data-causeway-collection-grid></${CAUSEWAY_COLLECTION_GRID}>${pager}${errors}`));
const adapter = this.querySelector?.(`${CAUSEWAY_COLLECTION_GRID}[data-causeway-collection-grid]`);
if (!adapter || revision !== this.gridHostRevision || !this.isConnected) return;
adapter.presentation = {
@@ -750,6 +764,14 @@ function humanize(value) {
return String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, character => character.toUpperCase());
}
+function firstNonBlank(...values) {
+ return values.find(value => String(value ?? '').trim())?.trim() ?? '';
+}
+
+function normalizedText(value) {
+ return String(value ?? '').trim().toLocaleLowerCase();
+}
+
function collectionBreakpointPixels() {
const candidate = globalThis.getComputedStyle?.(globalThis.document?.documentElement)?.fontSize;
const rootFontSize = Number.parseFloat(candidate);
diff --git a/viewers/webcomponents/foundation/src/object-context-controller.mjs b/viewers/webcomponents/foundation/src/object-context-controller.mjs
index ba8c6579761..10e7ae474ce 100644
--- a/viewers/webcomponents/foundation/src/object-context-controller.mjs
+++ b/viewers/webcomponents/foundation/src/object-context-controller.mjs
@@ -1301,9 +1301,15 @@ function translateRequirement(requirement, description) {
throw new Error(`Collection '${requirement.member}' does not expose readable contents.`);
}
const supportedFields = ['hidden', 'disabled'].filter(field => member.fields.has(field));
+ const memberSelection = Object.fromEntries(supportedFields.map(field => [field, true]));
+ const metadataFields = ['friendlyName', 'description']
+ .filter(field => member.metadata?.fields.has(field));
+ if (metadataFields.length > 0) {
+ memberSelection.metadata = Object.fromEntries(metadataFields.map(field => [field, true]));
+ }
return {
descriptor: member,
- selection: {[member.id]: Object.fromEntries(supportedFields.map(field => [field, true]))}
+ selection: {[member.id]: memberSelection}
};
}
diff --git a/viewers/webcomponents/foundation/src/theme.css b/viewers/webcomponents/foundation/src/theme.css
index 23a59da2914..2ea8f618b06 100644
--- a/viewers/webcomponents/foundation/src/theme.css
+++ b/viewers/webcomponents/foundation/src/theme.css
@@ -355,13 +355,19 @@ cw-property:last-child .causeway-property {
text-align: start;
}
-.causeway-property-description,
-.causeway-collection-description {
+.causeway-property-description {
grid-column: 2 / -1;
color: var(--causeway-muted);
font-size: var(--causeway-font-size-sm);
}
+.causeway-collection-description {
+ margin: 0;
+ padding: var(--causeway-space-2) var(--causeway-space-4);
+ color: var(--causeway-muted);
+ font-size: var(--causeway-font-size-sm);
+}
+
:where(cw-property[multiline]) .causeway-property-label {
grid-column: 1;
grid-row: 1;
diff --git a/viewers/webcomponents/foundation/test/collection.test.mjs b/viewers/webcomponents/foundation/test/collection.test.mjs
index 972b2479023..e27e440322c 100644
--- a/viewers/webcomponents/foundation/test/collection.test.mjs
+++ b/viewers/webcomponents/foundation/test/collection.test.mjs
@@ -558,6 +558,87 @@ test('collection component does not read until activated and renders declared co
assert.equal(collection.hidden, true);
});
+test('collection component resolves canonical and HTML headings without unmodifiable noise or rereads', async () => {
+ let registrations = 0;
+ let publish;
+ const context = {
+ registerRequirement(_requirement, listener) {
+ registrations += 1;
+ publish = listener;
+ listener({
+ status: 'ready',
+ descriptor: {id: 'staffMembers'},
+ data: {
+ hidden: false,
+ disabled: 'Cannot edit a mixed-in collection.',
+ metadata: {friendlyName: 'Department staff', description: 'Current staff members.'}
+ },
+ errors: [],
+ generation: 1
+ });
+ return () => {};
+ }
+ };
+ const collection = new CausewayCollectionElement();
+ collection.id = 'staffMembers';
+ collection.context = context;
+ document.body.appendChild(collection);
+ await waitFor(() => collection.innerHTML.includes('Department staff'));
+
+ assert.deepEqual(CausewayCollectionElement.observedAttributes, ['id', 'named', 'described-as', 'label', 'active']);
+ assert.match(collection.innerHTML, /class="causeway-collection-label">Department staff<\/h2>/);
+ assert.match(collection.innerHTML, /class="causeway-collection-description">Current staff members\.<\/p>/);
+ assert.match(collection.innerHTML, /aria-labelledby="causeway-collection-label-/);
+ assert.match(collection.innerHTML, /aria-describedby="causeway-collection-description-/);
+ assert.doesNotMatch(collection.innerHTML, /Cannot edit a mixed-in collection/);
+ assert.doesNotMatch(collection.innerHTML, /causeway-collection-disabled-reason/);
+
+ publish({
+ status: 'partial-error',
+ descriptor: {id: 'staffMembers'},
+ data: {
+ hidden: false,
+ disabled: null,
+ metadata: {friendlyName: 'Department staff', description: null}
+ },
+ errors: [{message: 'Collection description is unavailable.'}],
+ generation: 2
+ });
+ assert.match(collection.innerHTML, /Department staff/);
+ assert.doesNotMatch(collection.innerHTML, /causeway-collection-description/);
+ assert.doesNotMatch(collection.innerHTML, /causeway-error/);
+ publish({
+ status: 'ready',
+ descriptor: {id: 'staffMembers'},
+ data: {
+ hidden: false,
+ disabled: null,
+ metadata: {friendlyName: 'Department staff', description: 'Current staff members.'}
+ },
+ errors: [],
+ generation: 3
+ });
+
+ collection.label = 'Legacy staff label';
+ assert.match(collection.innerHTML, /Legacy staff label/);
+ collection.named = 'Priority team';
+ collection.describedAs = 'People assigned to priority cases.';
+ assert.match(collection.innerHTML, /class="causeway-collection-label">Priority team<\/h2>/);
+ assert.match(collection.innerHTML, /class="causeway-collection-description">People assigned to priority cases\.<\/p>/);
+
+ collection.describedAs = ' priority TEAM ';
+ assert.doesNotMatch(collection.innerHTML, /causeway-collection-description/);
+ assert.doesNotMatch(collection.innerHTML, /aria-describedby/);
+
+ collection.removeAttribute('named');
+ collection.removeAttribute('described-as');
+ collection.removeAttribute('label');
+ assert.match(collection.innerHTML, /Department staff/);
+ assert.match(collection.innerHTML, /Current staff members/);
+ assert.equal(registrations, 1);
+ document.body.removeChild(collection);
+});
+
test('collection component forwards window requests and publishes semantic window state', async () => {
let request;
const context = {
diff --git a/viewers/webcomponents/foundation/test/component-styles.test.mjs b/viewers/webcomponents/foundation/test/component-styles.test.mjs
index a4906d8218f..1bbe5cef39f 100644
--- a/viewers/webcomponents/foundation/test/component-styles.test.mjs
+++ b/viewers/webcomponents/foundation/test/component-styles.test.mjs
@@ -56,6 +56,12 @@ test('disabled reasons use label-owned tooltips and string values align to logic
assert.match(theme, /\.causeway-property-value-string \{\s+text-align: start;/);
});
+test('collection descriptions remain quiet text directly below their labels', async () => {
+ const theme = await readFile(new URL('../src/theme.css', import.meta.url), 'utf8');
+ assert.match(theme, /\.causeway-collection-description \{\s+margin: 0;\s+padding: var\(--causeway-space-2\) var\(--causeway-space-4\);\s+color: var\(--causeway-muted\);\s+font-size: var\(--causeway-font-size-sm\);/);
+ assert.doesNotMatch(theme, /\.causeway-property-description,\s+\.causeway-collection-description/);
+});
+
test('application native-control chrome does not cross into toolkit-owned slotted field inputs', async () => {
const theme = await readFile(new URL('../src/theme.css', import.meta.url), 'utf8');
const nativeInput = 'input:not([slot="input"])';
diff --git a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
index 05b6645f0bc..f16a1d748c8 100644
--- a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
+++ b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
@@ -113,12 +113,14 @@ export function createRichSchemaTypes() {
[COLLECTION_TYPE, objectType(COLLECTION_TYPE, null, [
field('hidden', null, scalar('Boolean')),
field('disabled', null, scalar('String')),
+ field('metadata', null, named(MEMBER_METADATA_TYPE)),
field('get', null, list(named(STAFF_OBJECT_TYPE))),
field('datatype', null, scalar('String'))
])],
[EMPTY_COLLECTION_TYPE, objectType(EMPTY_COLLECTION_TYPE, null, [
field('hidden', null, scalar('Boolean')),
field('disabled', null, scalar('String')),
+ field('metadata', null, named(MEMBER_METADATA_TYPE)),
field('get', null, list(named(STAFF_OBJECT_TYPE))),
field('datatype', null, scalar('String'))
])],
@@ -301,8 +303,16 @@ export function departmentObjectData({
get: {name: 'history.txt', mimeType: 'text/plain', chars: '/graphql/object/history/clobChars'}
},
unsupportedValue: {hidden: false, disabled: null, get: {nested: {value: 'unknown'}}},
- staffMembers: {hidden: false, disabled: null},
- formerStaff: {hidden: false, disabled: null},
+ staffMembers: {
+ hidden: false,
+ disabled: null,
+ metadata: {friendlyName: 'Department staff', description: 'Current staff members.'}
+ },
+ formerStaff: {
+ hidden: false,
+ disabled: null,
+ metadata: {friendlyName: 'Former staff', description: null}
+ },
changeName: {hidden: false, disabled: null}
};
}
diff --git a/viewers/webcomponents/foundation/test/member-composition.test.mjs b/viewers/webcomponents/foundation/test/member-composition.test.mjs
index 165cfe6948f..e79cb13c804 100644
--- a/viewers/webcomponents/foundation/test/member-composition.test.mjs
+++ b/viewers/webcomponents/foundation/test/member-composition.test.mjs
@@ -191,7 +191,9 @@ test('collection keeps interleaved columns and actions in separate vocabularies'
assert.equal(collection.childNodes[0].hidden, true);
assert.equal(addPet.isConnected, true);
context.publish('collection:pets', collectionState({disabled: 'Collection is locked'}));
- assert.match(collection.innerHTML, /Collection is locked/);
+ assert.doesNotMatch(collection.innerHTML, /Collection is locked/);
+ assert.equal(addPet.isConnected, true);
+ assert.equal(removePet.isConnected, true);
assert.deepEqual(collection.columns.map(candidate => candidate.member), ['name', 'species']);
assert.equal(context.count('action:addPet'), 1);
assert.equal(context.count('action:removePet'), 1);
diff --git a/viewers/webcomponents/foundation/test/object-context.test.mjs b/viewers/webcomponents/foundation/test/object-context.test.mjs
index 953f4fffa56..67569c9397f 100644
--- a/viewers/webcomponents/foundation/test/object-context.test.mjs
+++ b/viewers/webcomponents/foundation/test/object-context.test.mjs
@@ -133,6 +133,56 @@ test('property reads remain compatible when member metadata is unavailable', asy
assert.equal(propertyState.data.get, 'Classics');
});
+test('collection reads include only supported canonical heading metadata', async () => {
+ const executor = createRichSchemaFixtureExecutor();
+ const context = createContext(executor);
+ let collectionState;
+ context.registerRequirement({kind: 'collection', member: 'staffMembers'}, state => { collectionState = state; });
+ await waitFor(() => context.state.status === 'ready');
+
+ assert.equal(executor.readCalls.length, 1);
+ const collectionDocument = executor.readCalls[0].document;
+ assert.match(collectionDocument, /staffMembers\s*\{[\s\S]*metadata\s*\{/);
+ assert.match(collectionDocument, /\bfriendlyName\b/);
+ assert.match(collectionDocument, /\bdescription\b/);
+ assert.deepEqual(collectionState.data.metadata, {
+ friendlyName: 'Department staff',
+ description: 'Current staff members.'
+ });
+
+ const metadataErrorResponse = graphQLObjectResponse();
+ metadataErrorResponse.data.rich[DEPARTMENT_OBJECT_FIELD].staffMembers.metadata.description = null;
+ metadataErrorResponse.errors = [{
+ message: 'Collection description is unavailable.',
+ path: ['rich', DEPARTMENT_OBJECT_FIELD, 'staffMembers', 'metadata', 'description'],
+ extensions: {classification: 'DataFetchingException'}
+ }];
+ const metadataErrorExecutor = createRichSchemaFixtureExecutor({readResponses: [metadataErrorResponse]});
+ const metadataErrorContext = createContext(metadataErrorExecutor);
+ let metadataErrorState;
+ metadataErrorContext.registerRequirement({kind: 'collection', member: 'staffMembers'}, state => { metadataErrorState = state; });
+ await waitFor(() => metadataErrorContext.state.status === 'partial-error');
+ assert.equal(metadataErrorState.status, 'partial-error');
+ assert.equal(metadataErrorState.data.metadata.friendlyName, 'Department staff');
+ assert.equal(metadataErrorState.data.metadata.description, null);
+
+ const types = createRichSchemaTypes();
+ for (const [name, type] of types) {
+ if (type.fields?.some(field => field.name === 'friendlyName')) {
+ types.set(name, {...type, fields: type.fields.filter(field => field.name !== 'description')});
+ }
+ }
+ const partialExecutor = createRichSchemaFixtureExecutor({types});
+ const partialContext = createContext(partialExecutor);
+ partialContext.registerRequirement({kind: 'collection', member: 'staffMembers'});
+ await waitFor(() => partialContext.state.status === 'ready');
+ const collectionSelection = partialExecutor.readCalls[0].document.slice(
+ partialExecutor.readCalls[0].document.indexOf('staffMembers {')
+ );
+ assert.match(collectionSelection, /metadata\s*\{[\s\S]*friendlyName/);
+ assert.doesNotMatch(collectionSelection, /\bdescription\b/);
+});
+
test('coalesces layout metadata with semantic member requirements', async () => {
const executor = createRichSchemaFixtureExecutor();
const context = createContext(executor);
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.java
index 0851b3203da..01083d9e3fb 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.java
@@ -60,7 +60,9 @@ public List<PetOwner> getPetOwners() {
}
@Collection
- @CollectionLayout(paged = 10)
+ @CollectionLayout(
+ paged = 10,
+ describedAs = "Scheduled visits that have not yet taken place.")
public List<Visit> getFutureVisits() {
final LocalDateTime now = clockService.getClock().nowAsLocalDateTime();
return visitRepository.findByVisitAtAfter(now);
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java
index 792497b5f48..3329f504227 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java
@@ -110,7 +110,9 @@ public class PetOwner implements Comparable<PetOwner> {
private LocalDate lastVisit;
@Collection
- @CollectionLayout(sortedBy = Pet.ByName.class)
+ @CollectionLayout(
+ sortedBy = Pet.ByName.class,
+ describedAs = "Pets currently registered to this owner.")
@OneToMany(mappedBy = "petOwner", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Pet> pets = new TreeSet<>();
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.HomePage.html b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.HomePage.html
index c065e60886d..4549c8529e0 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.HomePage.html
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.HomePage.html
@@ -26,7 +26,7 @@ <h2 id="petclinic-owner-heading">Pet owners</h2>
</section>
<section aria-labelledby="petclinic-visit-heading">
<h2 id="petclinic-visit-heading">Upcoming visits</h2>
- <cw-collection id="futureVisits" label="Upcoming visits" active offset="0" size="10">
+ <cw-collection id="futureVisits" named="Next appointments" active offset="0" size="10">
<cw-collection-column id="visitAt" label="When"></cw-collection-column>
<cw-collection-column id="reason" label="Reason"></cw-collection-column>
<cw-collection-column id="notes" label="Notes"></cw-collection-column>
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html
index cc516415d6c..3c8a91e595b 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html
@@ -55,7 +55,7 @@ <h2 id="petclinic-owner-details-heading">Details</h2>
<div class="petclinic-object-collections">
<section class="petclinic-card" aria-labelledby="petclinic-owner-pets-heading">
<h2 id="petclinic-owner-pets-heading">Pets</h2>
- <cw-collection id="pets" label="Pets" active offset="0" size="10">
+ <cw-collection id="pets" named="Companion animals" active offset="0" size="10">
<cw-collection-column id="name" label="Name"></cw-collection-column>
<cw-collection-column id="species" label="Species"></cw-collection-column>
<cw-collection-column id="notes" label="Notes"></cw-collection-column>
@@ -66,7 +66,9 @@ <h2 id="petclinic-owner-pets-heading">Pets</h2>
<section class="petclinic-card" aria-labelledby="petclinic-owner-visits-heading">
<h2 id="petclinic-owner-visits-heading">Visits</h2>
- <cw-collection id="visits" label="Visits" active offset="0" size="10">
+ <cw-collection id="visits" named="Visit history"
+ described-as="All visits recorded for this owner's pets."
+ active offset="0" size="10">
<cw-collection-column id="visitAt" label="When"></cw-collection-column>
<cw-collection-column id="reason" label="Reason"></cw-collection-column>
<cw-collection-column id="notes" label="Notes"></cw-collection-column>
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 896fabcb33e..b8fee8c5d7c 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
@@ -199,6 +199,12 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
assertFocused(ROUTE_PAGE);
waitForCollectionRows("petOwners", 4);
waitForCollectionRows("futureVisits", 3);
+ assertThat(page.locator("cw-collection[id='futureVisits'] .causeway-collection-label").innerText())
+ .isEqualTo("Next appointments");
+ assertThat(page.locator("cw-collection[id='futureVisits'] .causeway-collection-description").innerText())
+ .isEqualTo("Scheduled visits that have not yet taken place.");
+ assertThat(page.locator("cw-collection[id='petOwners'] .causeway-collection-description").count())
+ .isZero();
assertCollectionPresentation("petOwners", "narrow");
assertCollectionPresentation("futureVisits", "narrow");
assertThat(toolkitRequests.stream().filter(url -> url.contains("/vaadin-menubar/vaadin-menubar.js")).count())
@@ -223,6 +229,12 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
assertFocused(ROUTE_PAGE);
waitForCollectionRows("pets", 2);
waitForCollectionRows("visits", 2);
+ assertCollectionHeading("pets", "Companion animals", "Pets currently registered to this owner.");
+ assertCollectionHeading("visits", "Visit history", "All visits recorded for this owner's pets.");
+ assertThat(page.locator("cw-collection .causeway-collection-disabled-reason").count()).isZero();
+ assertThat(page.locator("cw-collection [title*='Cannot edit']").count()).isZero();
+ assertThat(page.locator("cw-collection").allTextContents())
+ .noneMatch(text -> text.contains("Cannot edit a mixed-in collection"));
assertCollectionPresentation("pets", "grid");
assertCollectionPresentation("visits", "ordering-not-deterministic");
assertThat(page.locator(".petclinic-page-toolbar cw-action[id='delete']").count()).isEqualTo(1);
@@ -1035,6 +1047,17 @@ private void waitForCollectionRows(final String member, final int count) {
}
}
+ private void assertCollectionHeading(
+ final String member,
+ final String expectedName,
+ final String expectedDescription) {
+ final var collection = page.locator("cw-collection[id='" + member + "']");
+ assertThat(collection.locator(".causeway-collection-label").innerText()).isEqualTo(expectedName);
+ assertThat(collection.locator(".causeway-collection-description").innerText()).isEqualTo(expectedDescription);
+ assertThat(collection.locator(".causeway-collection").getAttribute("aria-labelledby")).isNotBlank();
+ assertThat(collection.locator(".causeway-collection").getAttribute("aria-describedby")).isNotBlank();
+ }
+
private void assertCollectionPresentation(final String member, final String expected) {
final var collection = page.locator("cw-collection[id='" + member + "']");
if (nativeToolkit() || !"grid".equals(expected)) {
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
index bc83c37d7ef..04a2291ccf8 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
@@ -216,12 +216,22 @@ void packagesHtmlPagesAndRetainsLayoutFallbackResources() throws Exception {
.contains("id=\"knownAs\" editable\n described-as=\"The familiar or preferred name used by this owner.\"")
.contains("id=\"notes\" editable multi-line=\"5\"")
.contains("id=\"lastVisit\" editable label-position=\"TOP\"")
- .contains("<cw-collection id=\"pets\"")
+ .contains("<cw-collection id=\"pets\" named=\"Companion animals\"")
.contains("<cw-action id=\"addPet\"")
.contains("<cw-action id=\"removePet\"")
- .contains("<cw-collection id=\"visits\"")
+ .contains("<cw-collection id=\"visits\" named=\"Visit history\"")
+ .contains("described-as=\"All visits recorded for this owner's pets.\"")
.contains("<cw-action id=\"bookVisit\"")
.doesNotContain("petclinic-associated-actions", "petclinic-member-composition");
+ final String homeHtml;
+ try (var input = loader.getResource(
+ "META-INF/causeway/webcomponents/pages/petclinic.HomePage.html").openStream()) {
+ homeHtml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ assertThat(homeHtml)
+ .contains("<cw-collection id=\"futureVisits\" named=\"Next appointments\"")
+ .contains("<cw-collection id=\"petOwners\" label=\"Pet owners\"")
+ .doesNotContain("id=\"petOwners\" named=", "id=\"petOwners\" described-as=");
assertThat(ownerHtml.indexOf("id=\"addPet\""))
.isLessThan(ownerHtml.indexOf("id=\"removePet\""));
assertThat(get("/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html").statusCode())
@@ -319,8 +329,8 @@ void exposesPetclinicServiceAndObjectInteractionsThroughRichGraphQL() throws Exc
petclinic_PetOwner(object: {id: "s_owner-mary"}) {
_meta { id logicalTypeName title grid }
name { get }
- pets { get { _meta { id logicalTypeName title } } }
- visits { get { _meta { id logicalTypeName title } } }
+ pets { metadata { friendlyName description } get { _meta { id logicalTypeName title } } }
+ visits { metadata { friendlyName description } get { _meta { id logicalTypeName title } } }
addPet { hidden disabled params { name { validity datatype } } }
bookVisit { hidden disabled }
}
@@ -329,6 +339,11 @@ void exposesPetclinicServiceAndObjectInteractionsThroughRichGraphQL() throws Exc
""");
assertNoGraphQLErrors(owner);
assertThat(owner.at("/data/rich/petclinic_PetOwner/name/get").asText()).isEqualTo("Mary Smith");
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/pets/metadata/friendlyName").asText()).isEqualTo("Pets");
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/pets/metadata/description").asText())
+ .isEqualTo("Pets currently registered to this owner.");
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/visits/metadata/friendlyName").asText()).isEqualTo("Visits");
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/visits/metadata/description").isNull()).isTrue();
assertThat(owner.at("/data/rich/petclinic_PetOwner/pets/get").size()).isEqualTo(2);
assertThat(owner.at("/data/rich/petclinic_PetOwner/visits/get").size()).isEqualTo(2);
}