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/30 06:41:26 UTC
(causeway) 02/05: feat(webcomponents): add collection sorting and filtering
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 ceb71ba953faba7191c283381a35aa6f1db70981
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sun Aug 30 03:11:56 2026 +0200
feat(webcomponents): add collection sorting and filtering
---
.../add-collection-sorting-and-filtering/tasks.md | 28 ++--
.../rich/query/CollectionWindowOrdering.java | 1 +
.../domain/rich/query/RichCollectionWindow.java | 131 ++++++++++++---
.../dept/StaffMemberCollectionFilterService.java | 45 +++++
...ach.collection_wide_sorting_and_filtering._.gql | 27 +++
...ection_wide_sorting_and_filtering.approved.json | 41 +++++
...ach.introspects_window_capability.approved.json | 30 ++++
....each.introspects_window_ordering.approved.json | 3 +
...st.each.introspects_window_result.approved.json | 27 +++
...h.invalid_collection_criteria_are_bounded._.gql | 17 ++
...d_collection_criteria_are_bounded.approved.json | 28 ++++
viewers/webcomponents/foundation/USAGE.adoc | 15 +-
.../foundation/src/collection-element.mjs | 183 +++++++++++++++++++--
.../src/collection-grid-qualification.mjs | 4 +-
.../foundation/src/component-styles.css | 23 +++
.../foundation/src/component-styles.mjs | 23 +++
.../foundation/src/graphql-client.mjs | 26 ++-
.../webcomponents/foundation/src/grid-widget.mjs | 47 +++++-
.../foundation/src/object-context-controller.mjs | 68 +++++++-
viewers/webcomponents/foundation/src/selection.mjs | 46 +++++-
.../test/collection-grid-qualification.test.mjs | 9 +
.../foundation/test/collection.test.mjs | 146 ++++++++++++++++
.../test/fixtures/rich-schema-fixture.mjs | 59 ++++++-
.../foundation/test/grid-widget.test.mjs | 25 +++
.../domain/PetOwnerCollectionFilterService.java | 52 ++++++
.../webcomponents/pages/petclinic.HomePage.html | 2 +-
.../webcomponents/pages/petclinic.PetOwner.html | 2 +-
.../petclinic/PetClinicHtmxPlaywrightTest.java | 19 +++
.../PetClinicHtmxApplication_IntegTest.java | 30 +++-
29 files changed, 1084 insertions(+), 73 deletions(-)
diff --git a/openspec/changes/add-collection-sorting-and-filtering/tasks.md b/openspec/changes/add-collection-sorting-and-filtering/tasks.md
index 05e9c6d08e5..324e38c6866 100644
--- a/openspec/changes/add-collection-sorting-and-filtering/tasks.md
+++ b/openspec/changes/add-collection-sorting-and-filtering/tasks.md
@@ -1,28 +1,28 @@
## 1. Rich GraphQL collection criteria
-- [ ] 1.1 Add backward-compatible bounded sort and search arguments plus capability metadata to rich collection windows.
-- [ ] 1.2 Apply accepted `DataTableInteractive` filtering and single-column sorting before slicing while preserving authorization, configured ordering, and bounded validation.
-- [ ] 1.3 Add model and end-to-end GraphQL tests for discovery, sorting, filtering, invalid criteria, counts, paging, and unchanged offset-and-size documents.
+- [x] 1.1 Add backward-compatible bounded sort and search arguments plus capability metadata to rich collection windows.
+- [x] 1.2 Apply accepted `DataTableInteractive` filtering and single-column sorting before slicing while preserving authorization, configured ordering, and bounded validation.
+- [x] 1.3 Add model and end-to-end GraphQL tests for discovery, sorting, filtering, invalid criteria, counts, paging, and unchanged offset-and-size documents.
## 2. Foundation criteria transport
-- [ ] 2.1 Discover criteria arguments and capability fields and include normalized criteria in collection-window operation variables and cache identities.
-- [ ] 2.2 Carry current criteria through initial loads, paging, refresh, virtual ranges, broker generations, and stale-contract checks.
-- [ ] 2.3 Add operation, context, range, and compatibility tests proving complete-window semantics and safe fallback on older schemas.
+- [x] 2.1 Discover criteria arguments and capability fields and include normalized criteria in collection-window operation variables and cache identities.
+- [x] 2.2 Carry current criteria through initial loads, paging, refresh, virtual ranges, broker generations, and stale-contract checks.
+- [x] 2.3 Add operation, context, range, and compatibility tests proving complete-window semantics and safe fallback on older schemas.
## 3. Collection presentation
-- [ ] 3.1 Add observed default-off `sortable` and `filterable` attributes with bounded reactive criterion state and offset-zero reload.
-- [ ] 3.2 Render accessible native and Grid header sort controls plus one capability-gated bounded quick-search control without exposing toolkit APIs.
-- [ ] 3.3 Preserve focus, empty/loading/error presentation, native fallback, paging, associated actions, and responsive transitions under active criteria.
-- [ ] 3.4 Update foundation usage documentation and adapter/collection tests for defaults, criteria cycles, filtering, stale work, and unsupported capability behavior.
+- [x] 3.1 Add observed default-off `sortable` and `filterable` attributes with bounded reactive criterion state and offset-zero reload.
+- [x] 3.2 Render accessible native and Grid header sort controls plus one capability-gated bounded quick-search control without exposing toolkit APIs.
+- [x] 3.3 Preserve focus, empty/loading/error presentation, native fallback, paging, associated actions, and responsive transitions under active criteria.
+- [x] 3.4 Update foundation usage documentation and adapter/collection tests for defaults, criteria cycles, filtering, stale work, and unsupported capability behavior.
## 4. Petclinic demonstration
-- [ ] 4.1 Add bounded owner filtering tokens through an application `CollectionFilterService`.
-- [ ] 4.2 Opt selected Petclinic collections into sorting and filtering while leaving unselected collections unchanged.
-- [ ] 4.3 Update Petclinic integration and Playwright coverage for cross-page sorting, filtering, clearing, accessibility, and associated-action continuity.
+- [x] 4.1 Add bounded owner filtering tokens through an application `CollectionFilterService`.
+- [x] 4.2 Opt selected Petclinic collections into sorting and filtering while leaving unselected collections unchanged.
+- [x] 4.3 Update Petclinic integration and Playwright coverage for cross-page sorting, filtering, clearing, accessibility, and associated-action continuity.
## 5. Verification
-- [ ] 5.1 Run GraphQL model and end-to-end tests, foundation Node and Maven tests, Petclinic integration and default/native browser tests, RAT, compilation or inspections, diff checks, and strict OpenSpec validation.
+- [x] 5.1 Run GraphQL model and end-to-end tests, foundation Node and Maven tests, Petclinic integration and default/native browser tests, RAT, compilation or inspections, diff checks, and strict OpenSpec validation.
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/CollectionWindowOrdering.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/CollectionWindowOrdering.java
index 36d04f435ad..a0c536be83c 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/CollectionWindowOrdering.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/CollectionWindowOrdering.java
@@ -20,5 +20,6 @@
enum CollectionWindowOrdering {
CONFIGURED,
+ REQUESTED,
ENCOUNTER
}
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichCollectionWindow.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichCollectionWindow.java
index 30b3fa1886b..6d79c01f38d 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichCollectionWindow.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichCollectionWindow.java
@@ -18,10 +18,10 @@
*/
package org.apache.causeway.viewer.graphql.model.domain.rich.query;
-import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.IntStream;
import graphql.Scalars;
import graphql.schema.DataFetchingEnvironment;
@@ -32,11 +32,15 @@
import static graphql.schema.GraphQLEnumType.newEnum;
import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition;
+import static graphql.schema.GraphQLList.list;
import static graphql.schema.GraphQLNonNull.nonNull;
import static graphql.schema.GraphQLObjectType.newObject;
-import org.apache.causeway.core.metamodel.object.ManagedObject;
+import org.apache.causeway.applib.annotation.Where;
+import org.apache.causeway.core.metamodel.interactions.managed.ManagedCollection;
+import org.apache.causeway.core.metamodel.object.MmSortUtils;
import org.apache.causeway.core.metamodel.spec.feature.OneToManyAssociation;
+import org.apache.causeway.core.metamodel.tabular.DataTableInteractive;
import org.apache.causeway.viewer.graphql.model.context.Context;
import org.apache.causeway.viewer.graphql.model.domain.Element;
import org.apache.causeway.viewer.graphql.model.domain.TypeNames;
@@ -46,6 +50,9 @@
public class RichCollectionWindow extends Element {
private static final String ORDERING_TYPE_NAME = "rich__gqlv_collection_window_ordering";
+ private static final String SORT_DIRECTION_TYPE_NAME = "rich__gqlv_collection_window_sort_direction";
+ private static final int MAX_SEARCH_LENGTH = 256;
+ private static final int MAX_SORT_MEMBER_LENGTH = 128;
private final MemberInteractor<OneToManyAssociation> memberInteractor;
private final int defaultWindowSize;
@@ -67,6 +74,7 @@ public RichCollectionWindow(
}
var orderingType = orderingType(context);
+ var sortDirectionType = sortDirectionType(context);
var windowType = windowType(context, memberInteractor, rowsType, orderingType);
setField(newFieldDefinition()
.name("window")
@@ -84,6 +92,20 @@ public RichCollectionWindow(
+ maxWindowSize + ".")
.type(nonNull(Scalars.GraphQLInt))
.defaultValueProgrammatic(defaultWindowSize))
+ .argument(GraphQLArgument.newArgument()
+ .name("sortBy")
+ .description("Optional accepted Causeway table-column member id.")
+ .type(Scalars.GraphQLString))
+ .argument(GraphQLArgument.newArgument()
+ .name("sortDirection")
+ .description("Direction for an optional sort member.")
+ .type(nonNull(sortDirectionType))
+ .defaultValueProgrammatic(MmSortUtils.SortDirection.ASCENDING))
+ .argument(GraphQLArgument.newArgument()
+ .name("search")
+ .description("Optional CollectionFilterService quick-search text, bounded to "
+ + MAX_SEARCH_LENGTH + " characters.")
+ .type(Scalars.GraphQLString))
.build());
}
@@ -91,7 +113,10 @@ public RichCollectionWindow(
protected Object fetchData(final DataFetchingEnvironment environment) {
var offset = environment.<Integer>getArgument("offset");
var size = environment.<Integer>getArgument("size");
- validate(offset, size);
+ var sortBy = normalize(environment.<String>getArgument("sortBy"));
+ var sortDirection = environment.<MmSortUtils.SortDirection>getArgument("sortDirection");
+ var search = normalize(environment.<String>getArgument("search"));
+ validate(offset, size, sortBy, sortDirection, search);
var association = memberInteractor.getObjectMember();
var managedObject = RichCollectionAccess.visibleSource(environment, association, context);
@@ -99,16 +124,31 @@ protected Object fetchData(final DataFetchingEnvironment environment) {
return null;
}
- var resultManagedObject = association.get(managedObject);
- var rows = materialize(resultManagedObject != null ? resultManagedObject.getPojo() : null);
- var comparator = association.getElementComparator();
- if (comparator.isPresent()) {
- var elementType = association.getElementType();
- var elementComparator = comparator.get();
- rows.sort((left, right) -> elementComparator.compare(
- ManagedObject.adaptSingular(elementType, left),
- ManagedObject.adaptSingular(elementType, right)));
+ var table = ManagedCollection.of(managedObject, association, Where.ANYWHERE).createDataTableModel();
+ var columns = table.dataColumnsObservable().getValue();
+ var sortableMembers = columns.stream()
+ .filter(column -> column.associationMetaModel().getSpecialization().leftIfAny() != null)
+ .map(column -> column.columnId())
+ .toList();
+ if (sortBy != null) {
+ var columnIndex = IntStream.range(0, columns.size())
+ .filter(index -> sortBy.equals(columns.getElseFail(index).columnId()))
+ .findFirst()
+ .orElseThrow(() -> invalid("Collection window sort member is not an accepted table column."));
+ if (!sortableMembers.contains(sortBy)) {
+ throw invalid("Collection window sort member is not sortable.");
+ }
+ table.columnSortBindable().setValue(new DataTableInteractive.ColumnSort(columnIndex, sortDirection));
}
+ if (search != null) {
+ if (!table.isSearchSupported()) {
+ throw invalid("Collection window search is not supported for this element type.");
+ }
+ table.searchArgumentBindable().setValue(search);
+ }
+ var rows = table.dataRowsFilteredAndSortedObservable().getValue().stream()
+ .map(row -> row.rowElement().getPojo())
+ .toList();
var totalCount = rows.size();
var fromIndex = Math.min(offset, totalCount);
@@ -124,13 +164,25 @@ protected Object fetchData(final DataFetchingEnvironment environment) {
result.put("maximumSize", maxWindowSize);
result.put("hasPrevious", offset > 0 && totalCount > 0);
result.put("hasNext", (long) offset + selectedRows.size() < totalCount);
- result.put("ordering", comparator.isPresent()
- ? CollectionWindowOrdering.CONFIGURED
- : CollectionWindowOrdering.ENCOUNTER);
+ result.put("ordering", sortBy != null
+ ? CollectionWindowOrdering.REQUESTED
+ : association.getElementComparator().isPresent()
+ ? CollectionWindowOrdering.CONFIGURED
+ : CollectionWindowOrdering.ENCOUNTER);
+ result.put("sortableMembers", sortableMembers);
+ result.put("searchSupported", table.isSearchSupported());
+ if (table.isSearchSupported() && !table.getSearchPromptPlaceholderText().isBlank()) {
+ result.put("searchPrompt", table.getSearchPromptPlaceholderText());
+ }
return Map.copyOf(result);
}
- private void validate(final Integer offset, final Integer size) {
+ private void validate(
+ final Integer offset,
+ final Integer size,
+ final String sortBy,
+ final MmSortUtils.SortDirection sortDirection,
+ final String search) {
if (offset == null || offset < 0) {
throw new InvalidCollectionWindowException(
"Collection window offset must be zero or greater.");
@@ -143,18 +195,26 @@ private void validate(final Integer offset, final Integer size) {
throw new InvalidCollectionWindowException(
"Collection window size exceeds the configured maximum of " + maxWindowSize + ".");
}
+ if (sortBy != null && sortBy.length() > MAX_SORT_MEMBER_LENGTH) {
+ throw invalid("Collection window sort member exceeds the supported length.");
+ }
+ if (sortBy != null && sortDirection == null) {
+ throw invalid("Collection window sort direction is required with a sort member.");
+ }
+ if (search != null && search.length() > MAX_SEARCH_LENGTH) {
+ throw invalid("Collection window search exceeds the supported length of " + MAX_SEARCH_LENGTH + ".");
+ }
}
- private static List<Object> materialize(final Object collection) {
- if (collection == null) {
- return new ArrayList<>();
- }
- if (!(collection instanceof Iterable<?> iterable)) {
- throw new IllegalStateException("Collection association did not return an iterable value.");
+ private static String normalize(final String value) {
+ if (value == null || value.isBlank()) {
+ return null;
}
- var rows = new ArrayList<>();
- iterable.forEach(rows::add);
- return rows;
+ return value.trim();
+ }
+
+ private static InvalidCollectionWindowException invalid(final String message) {
+ return new InvalidCollectionWindowException(message);
}
private static GraphQLEnumType orderingType(final Context context) {
@@ -165,6 +225,8 @@ private static GraphQLEnumType orderingType(final Context context) {
.description("How rows were ordered before selecting a collection window.")
.value(CollectionWindowOrdering.CONFIGURED.name(), CollectionWindowOrdering.CONFIGURED,
"A Causeway configured comparator was applied.")
+ .value(CollectionWindowOrdering.REQUESTED.name(), CollectionWindowOrdering.REQUESTED,
+ "A requested accepted Causeway table-column sort was applied.")
.value(CollectionWindowOrdering.ENCOUNTER.name(), CollectionWindowOrdering.ENCOUNTER,
"The materialized collection encounter order was retained without a cross-request stability guarantee.")
.build();
@@ -173,6 +235,20 @@ private static GraphQLEnumType orderingType(final Context context) {
});
}
+ private static GraphQLEnumType sortDirectionType(final Context context) {
+ return context.graphQLTypeRegistry.lookup(SORT_DIRECTION_TYPE_NAME, GraphQLEnumType.class)
+ .orElseGet(() -> {
+ var directionType = newEnum()
+ .name(SORT_DIRECTION_TYPE_NAME)
+ .description("Direction for a requested collection-window sort.")
+ .value(MmSortUtils.SortDirection.ASCENDING.name(), MmSortUtils.SortDirection.ASCENDING)
+ .value(MmSortUtils.SortDirection.DESCENDING.name(), MmSortUtils.SortDirection.DESCENDING)
+ .build();
+ context.graphQLTypeRegistry.addTypeIfNotAlreadyPresent(directionType);
+ return directionType;
+ });
+ }
+
private static GraphQLObjectType windowType(
final Context context,
final MemberInteractor<OneToManyAssociation> memberInteractor,
@@ -197,6 +273,11 @@ private static GraphQLObjectType windowType(
.field(newFieldDefinition().name("hasPrevious").type(nonNull(Scalars.GraphQLBoolean)))
.field(newFieldDefinition().name("hasNext").type(nonNull(Scalars.GraphQLBoolean)))
.field(newFieldDefinition().name("ordering").type(nonNull(orderingType)))
+ .field(newFieldDefinition().name("sortableMembers")
+ .type(nonNull(list(nonNull(Scalars.GraphQLString)))))
+ .field(newFieldDefinition().name("searchSupported")
+ .type(nonNull(Scalars.GraphQLBoolean)))
+ .field(newFieldDefinition().name("searchPrompt").type(Scalars.GraphQLString))
.build();
context.graphQLTypeRegistry.addTypeIfNotAlreadyPresent(type);
return type;
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/StaffMemberCollectionFilterService.java b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/StaffMemberCollectionFilterService.java
new file mode 100644
index 00000000000..92f0499e445
--- /dev/null
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/StaffMemberCollectionFilterService.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.graphql.viewer.test.domain.dept;
+
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.function.Function;
+
+import org.springframework.stereotype.Service;
+
+import org.apache.causeway.applib.services.filter.CollectionFilterService;
+
+@Service
+public class StaffMemberCollectionFilterService implements CollectionFilterService {
+
+ @Override
+ public boolean handles(final Class<?> elementType) {
+ return StaffMember.class.isAssignableFrom(elementType);
+ }
+
+ @Override
+ public <T> Function<T, Tokens> tokenizer(final Class<T> elementType) {
+ return candidate -> {
+ var normalizedName = ((StaffMember) candidate).getName().toLowerCase(Locale.ROOT);
+ return searchArgument -> Arrays.stream(searchArgument.toLowerCase(Locale.ROOT).split("\\s+"))
+ .allMatch(normalizedName::contains);
+ };
+ }
+}
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.collection_wide_sorting_and_filtering._.gql b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.collection_wide_sorting_and_filtering._.gql
new file mode 100644
index 00000000000..73635477a25
--- /dev/null
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.collection_wide_sorting_and_filtering._.gql
@@ -0,0 +1,27 @@
+{
+ rich {
+ university_dept_Departments {
+ findDepartmentByName {
+ invoke(name: "Physics") {
+ results {
+ staffMembers {
+ window(search: "Window 0", sortBy: "name", sortDirection: DESCENDING, offset: 2, size: 3) {
+ offset
+ requestedSize
+ returnedCount
+ totalCount
+ hasPrevious
+ hasNext
+ ordering
+ sortableMembers
+ searchSupported
+ searchPrompt
+ rows { name { get } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.collection_wide_sorting_and_filtering.approved.json b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.collection_wide_sorting_and_filtering.approved.json
new file mode 100644
index 00000000000..9d886a91f54
--- /dev/null
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.collection_wide_sorting_and_filtering.approved.json
@@ -0,0 +1,41 @@
+{
+ "data" : {
+ "rich" : {
+ "university_dept_Departments" : {
+ "findDepartmentByName" : {
+ "invoke" : {
+ "results" : {
+ "staffMembers" : {
+ "window" : {
+ "offset" : 2,
+ "requestedSize" : 3,
+ "returnedCount" : 3,
+ "totalCount" : 9,
+ "hasPrevious" : true,
+ "hasNext" : true,
+ "ordering" : "REQUESTED",
+ "sortableMembers" : [ "department", "name", "grade", "windowProbe", "photo", "profile" ],
+ "searchSupported" : true,
+ "searchPrompt" : "Search staff member",
+ "rows" : [ {
+ "name" : {
+ "get" : "Window 06"
+ }
+ }, {
+ "name" : {
+ "get" : "Window 05"
+ }
+ }, {
+ "name" : {
+ "get" : "Window 04"
+ }
+ } ]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_capability.approved.json b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_capability.approved.json
index 05bda6d8203..40b5611216a 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_capability.approved.json
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_capability.approved.json
@@ -70,6 +70,36 @@
"name" : "Int"
}
}
+ }, {
+ "name" : "sortBy",
+ "description" : "Optional accepted Causeway table-column member id.",
+ "defaultValue" : null,
+ "type" : {
+ "kind" : "SCALAR",
+ "name" : "String",
+ "ofType" : null
+ }
+ }, {
+ "name" : "sortDirection",
+ "description" : "Direction for an optional sort member.",
+ "defaultValue" : "ASCENDING",
+ "type" : {
+ "kind" : "NON_NULL",
+ "name" : null,
+ "ofType" : {
+ "kind" : "ENUM",
+ "name" : "rich__gqlv_collection_window_sort_direction"
+ }
+ }
+ }, {
+ "name" : "search",
+ "description" : "Optional CollectionFilterService quick-search text, bounded to 256 characters.",
+ "defaultValue" : null,
+ "type" : {
+ "kind" : "SCALAR",
+ "name" : "String",
+ "ofType" : null
+ }
} ],
"type" : {
"kind" : "OBJECT",
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_ordering.approved.json b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_ordering.approved.json
index 11f101f93e4..f4235cfb973 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_ordering.approved.json
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_ordering.approved.json
@@ -7,6 +7,9 @@
"enumValues" : [ {
"name" : "CONFIGURED",
"description" : "A Causeway configured comparator was applied."
+ }, {
+ "name" : "REQUESTED",
+ "description" : "A requested accepted Causeway table-column sort was applied."
}, {
"name" : "ENCOUNTER",
"description" : "The materialized collection encounter order was retained without a cross-request stability guarantee."
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_result.approved.json b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_result.approved.json
index 5270b73d0fb..083c546824e 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_result.approved.json
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.introspects_window_result.approved.json
@@ -91,6 +91,33 @@
"name" : "rich__gqlv_collection_window_ordering"
}
}
+ }, {
+ "name" : "sortableMembers",
+ "type" : {
+ "kind" : "NON_NULL",
+ "name" : null,
+ "ofType" : {
+ "kind" : "LIST",
+ "name" : null
+ }
+ }
+ }, {
+ "name" : "searchSupported",
+ "type" : {
+ "kind" : "NON_NULL",
+ "name" : null,
+ "ofType" : {
+ "kind" : "SCALAR",
+ "name" : "Boolean"
+ }
+ }
+ }, {
+ "name" : "searchPrompt",
+ "type" : {
+ "kind" : "SCALAR",
+ "name" : "String",
+ "ofType" : null
+ }
} ]
}
}
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.invalid_collection_criteria_are_bounded._.gql b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.invalid_collection_criteria_are_bounded._.gql
new file mode 100644
index 00000000000..0ee88dbda5d
--- /dev/null
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.invalid_collection_criteria_are_bounded._.gql
@@ -0,0 +1,17 @@
+{
+ rich {
+ university_dept_Departments {
+ findDepartmentByName {
+ invoke(name: "Physics") {
+ results {
+ staffMembers {
+ window(sortBy: "hiddenPhoto", size: 2) {
+ returnedCount
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.invalid_collection_criteria_are_bounded.approved.json b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.invalid_collection_criteria_are_bounded.approved.json
new file mode 100644
index 00000000000..703002b7afe
--- /dev/null
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/univ/dept/CollectionWindow_IntegTest.each.invalid_collection_criteria_are_bounded.approved.json
@@ -0,0 +1,28 @@
+{
+ "errors" : [ {
+ "message" : "Exception while fetching data (/rich/university_dept_Departments/findDepartmentByName/invoke/results/staffMembers/window) : Collection window sort member is not an accepted table column.",
+ "locations" : [ {
+ "column" : 15,
+ "line" : 8
+ } ],
+ "path" : [ "rich", "university_dept_Departments", "findDepartmentByName", "invoke", "results", "staffMembers", "window" ],
+ "extensions" : {
+ "classification" : "DataFetchingException"
+ }
+ } ],
+ "data" : {
+ "rich" : {
+ "university_dept_Departments" : {
+ "findDepartmentByName" : {
+ "invoke" : {
+ "results" : {
+ "staffMembers" : {
+ "window" : null
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/viewers/webcomponents/foundation/USAGE.adoc b/viewers/webcomponents/foundation/USAGE.adoc
index 4e66d138ce0..94e3523ea86 100644
--- a/viewers/webcomponents/foundation/USAGE.adoc
+++ b/viewers/webcomponents/foundation/USAGE.adoc
@@ -487,7 +487,7 @@ An authored property or collection can place object actions directly beneath the
</cw-property>
<cw-collection id="pets" named="Companion animals" described-as="Pets registered to this owner." description-as="tooltip" active
- paged="10" resizable-columns reorderable-columns>
+ paged="10" sortable filterable resizable-columns reorderable-columns>
<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>
@@ -541,7 +541,14 @@ When targeted introspection finds the additive rich `window(offset, size)` field
`paged="N"` requests bounded windows of `N` rows and renders Causeway-owned previous and next controls when the advertised window permits them.
The page size must be an integer from 1 through 100; absent, blank, malformed, fractional, zero, negative, and oversized values provide no override.
Changing the effective page size retires stale collection work and restarts at offset zero.
-Paging remains available in native table presentation and does not grant sorting or filtering semantics.
+Paging remains available in native table presentation.
+
+`sortable` enables accessible three-state heading buttons only for declared columns advertised by the server as sortable.
+A heading cycles through ascending, descending, and unsorted states, and each change applies before server window slicing and restarts at offset zero.
+`filterable` enables one bounded, labelled, debounced quick-search field only when an applicable `CollectionFilterService` is advertised.
+Search is capped at 256 characters, Escape and Clear remove it, and each accepted change filters before sorting and paging.
+Both attributes are opt-in and default off.
+Causeway owns criteria across native and Grid presentations; an older schema or unsupported capability exposes no misleading control and retains existing loading.
Under the default Vaadin component policy, an active connected collection wider than 48rem may use the internal independently packaged Grid closure when its window ordering is `CONFIGURED`, its semantic columns are fully projectable, and its total or paging metadata is safe.
A safe positive total selects virtual Grid and maps only bounded range callbacks through the Causeway-owned range broker unless explicit `paged` presentation requires the bounded Grid.
@@ -555,8 +562,8 @@ Both attributes are opt-in, default off, and have no effect in native fallback p
Set `causeway.viewer.webcomponents.htmx.component-toolkit=native` for complete collection and control rollback without changing GraphQL, routes, persisted data, or application markup.
The Grid closure is same-origin and route-lazy, has a 196608-byte gzip cap, and participates with Menu Bar in the 631808-byte aggregate closure cap and exact-hash CSP policy.
See `vaadin-grid/README.adoc` for pinned inputs, accepted checksum, legal metadata, CSP evidence, verification, and audit commands.
-Sorting, filtering, selection, editing, details, drag, Grid Pro, Flow, Binder, telemetry, CDN resources, server-side Vaadin state, and backend query pushdown remain outside the component contract.
-Column resizing and column reordering are supported only through the bounded public attributes above; no other Vaadin Grid API becomes application-facing.
+Multi-column sorting, per-column filtering, page-local array criteria, selection, editing, details, drag, Grid Pro, Flow, Binder, telemetry, CDN resources, server-side Vaadin state, and backend query pushdown remain outside the component contract.
+Sorting, quick search, column resizing, and column reordering are supported only through the bounded public attributes above; no other Vaadin Grid API becomes application-facing.
== Observable state, accessibility, and styling
diff --git a/viewers/webcomponents/foundation/src/collection-element.mjs b/viewers/webcomponents/foundation/src/collection-element.mjs
index 3cd74262f9b..bf0d24729ac 100644
--- a/viewers/webcomponents/foundation/src/collection-element.mjs
+++ b/viewers/webcomponents/foundation/src/collection-element.mjs
@@ -83,6 +83,8 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
'label',
'active',
'paged',
+ 'sortable',
+ 'filterable',
'resizable-columns',
'reorderable-columns'
];
@@ -94,6 +96,7 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
this.labelId = `causeway-collection-label-${sequence}`;
this.descriptionId = `causeway-collection-description-${sequence}`;
this.reasonId = `causeway-collection-reason-${sequence}`;
+ this.searchId = `causeway-collection-search-${sequence}`;
this._columns = [];
this._rendererRegistry = defaultValueRendererRegistry;
this.collectionState = Object.freeze({status: 'idle', data: null, errors: []});
@@ -103,6 +106,10 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
this.loadRevision = 0;
this.loadAbortController = null;
this.loadedGeneration = -1;
+ this.sortCriterion = null;
+ this.searchText = '';
+ this.searchTimer = null;
+ this.criteriaFocusIntent = null;
this.connectionStarted = false;
this.gridHostRevision = 0;
this.gridResponsiveRevision = 0;
@@ -154,6 +161,26 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
this.#loadNormalizedPage(this.collectionState.window?.previousOffset);
} else if (event.target?.hasAttribute?.('data-causeway-grid-next')) {
this.#loadNormalizedPage(this.collectionState.window?.nextOffset);
+ } else if (event.target?.hasAttribute?.('data-causeway-collection-sort')) {
+ this.#changeSort(event.target.getAttribute('data-causeway-collection-sort'));
+ } else if (event.target?.hasAttribute?.('data-causeway-collection-search-clear')) {
+ this.#commitSearch('');
+ }
+ });
+ this.addEventListener('input', event => {
+ if (!event.target?.hasAttribute?.('data-causeway-collection-search')) return;
+ const value = String(event.target.value ?? '').slice(0, MAX_COLLECTION_SEARCH_LENGTH);
+ globalThis.clearTimeout(this.searchTimer);
+ this.searchTimer = globalThis.setTimeout(() => this.#commitSearch(value), COLLECTION_SEARCH_DEBOUNCE_MS);
+ });
+ this.addEventListener('keydown', event => {
+ if (event.key !== 'Escape' || !event.target?.hasAttribute?.('data-causeway-collection-search')) return;
+ event.preventDefault?.();
+ globalThis.clearTimeout(this.searchTimer);
+ event.target.value = '';
+ if (!this.#commitSearch('')) {
+ this.criteriaFocusIntent = 'search';
+ this.renderComponentState(this.componentState);
}
});
this.addEventListener('focusin', event => {
@@ -168,8 +195,12 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
});
});
this.addEventListener('causeway-grid-ready', event => {
- if (!this.#focusIntentIsCurrent()) return;
- event.target?.restoreSemanticFocus?.(this.gridFocusIntent);
+ if (this.criteriaFocusIntent) {
+ const restored = event.target?.restoreSortFocus?.(this.criteriaFocusIntent);
+ if (restored) this.criteriaFocusIntent = null;
+ } else if (this.#focusIntentIsCurrent()) {
+ event.target?.restoreSemanticFocus?.(this.gridFocusIntent);
+ }
});
}
@@ -229,6 +260,24 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
}
}
+ get sortable() {
+ return this.hasAttribute('sortable');
+ }
+
+ set sortable(value) {
+ if (value === true) this.setAttribute('sortable', '');
+ else this.removeAttribute('sortable');
+ }
+
+ get filterable() {
+ return this.hasAttribute('filterable');
+ }
+
+ set filterable(value) {
+ if (value === true) this.setAttribute('filterable', '');
+ else this.removeAttribute('filterable');
+ }
+
get resizableColumns() {
return this.hasAttribute('resizable-columns');
}
@@ -322,6 +371,8 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
this.loadRevision += 1;
this.loadAbortController?.abort();
this.loadAbortController = null;
+ globalThis.clearTimeout(this.searchTimer);
+ this.searchTimer = null;
this.#disconnectRangeBroker();
for (const context of this.rowContexts) {
context.disconnect?.();
@@ -342,6 +393,9 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
this.loadAbortController = null;
this.#disconnectRangeBroker();
this.authoritativeResult = null;
+ this.sortCriterion = null;
+ this.searchText = '';
+ this.criteriaFocusIntent = null;
this.collectionState = Object.freeze({status: 'idle', data: null, errors: []});
this.loadedGeneration = -1;
this.reconnectRequirement();
@@ -357,6 +411,11 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
} else {
this.renderComponentState(this.componentState);
}
+ } else if (name === 'sortable' || name === 'filterable') {
+ if (!this.sortable) this.sortCriterion = null;
+ if (!this.filterable) this.searchText = '';
+ this.criteriaFocusIntent = null;
+ this.#reloadCriteria();
} else {
if (name === 'resizable-columns' || name === 'reorderable-columns') {
this.gridHostRevision += 1;
@@ -400,7 +459,7 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
this.authoritativeResult = null;
const abortController = new AbortController();
this.loadAbortController = abortController;
- this.collectionState = Object.freeze({status: 'loading', data: null, errors: []});
+ this.collectionState = Object.freeze({...this.collectionState, status: 'loading', data: null, errors: []});
this.renderComponentState(this.componentState);
this.#publishCollectionState();
try {
@@ -409,6 +468,9 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
columns: this._columns,
offset,
size: requestedSize,
+ sortBy: this.sortable ? this.sortCriterion?.member ?? null : null,
+ sortDirection: this.sortable ? this.sortCriterion?.direction ?? 'ASCENDING' : 'ASCENDING',
+ search: this.filterable ? this.searchText : null,
requestKey: this,
force,
signal: abortController.signal
@@ -514,18 +576,20 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
renderMemberPrimary(this, shell(`<button type="button" data-causeway-activate>Load ${escapeHtml(label)}</button>`));
return;
}
+ const criteriaControls = this.#renderCriteriaControls();
if (this.collectionState.status === 'idle' || this.collectionState.status === 'loading') {
- renderMemberPrimary(this, shell('<span role="status">Loading collection…</span>', ' aria-busy="true"'));
+ renderMemberPrimary(this, shell(`${criteriaControls}<span role="status">Loading collection…</span>`, ' aria-busy="true"'));
return;
}
if (this.collectionState.status === 'error') {
- renderMemberPrimary(this, shell(`<span class="causeway-error" role="alert">${escapeHtml(errorMessage(this.collectionState))}</span>`));
+ renderMemberPrimary(this, shell(`${criteriaControls}<span class="causeway-error" role="alert">${escapeHtml(errorMessage(this.collectionState))}</span>`));
return;
}
const rows = collectionRows(this.collectionState);
if (rows.length === 0) {
const pager = this.paged ? this.#renderBoundedPager(this.collectionState.window) : '';
- renderMemberPrimary(this, shell(`<span class="causeway-empty" role="status">No items</span>${pager}`));
+ renderMemberPrimary(this, shell(`${criteriaControls}<span class="causeway-empty" role="status">No items</span>${pager}`));
+ this.#restoreCriteriaFocus();
return;
}
const errors = this.collectionState.errors?.length
@@ -539,8 +603,9 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
? this.#renderTable(rows)
: this.#renderDefaultRows(rows);
const pager = this.paged ? this.#renderBoundedPager(this.collectionState.window) : '';
- renderMemberPrimary(this, shell(`${content}${pager}${errors}`));
+ renderMemberPrimary(this, shell(`${criteriaControls}${content}${pager}${errors}`));
this.#restoreNativeFocus();
+ this.#restoreCriteriaFocus();
}
#renderGrid(shell, errors, describedBy) {
@@ -548,7 +613,7 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
const bounded = this._gridQualification.presentation === 'grid-bounded';
const window = this.collectionState.window;
const pager = bounded ? this.#renderBoundedPager(window) : '';
- renderMemberPrimary(this, shell(`<${CAUSEWAY_COLLECTION_GRID} data-causeway-collection-grid></${CAUSEWAY_COLLECTION_GRID}>${pager}${errors}`));
+ renderMemberPrimary(this, shell(`${this.#renderCriteriaControls()}<${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 = {
@@ -562,8 +627,12 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
testId: this.getAttribute('data-testid') ? `${this.getAttribute('data-testid')}-grid` : '',
resizableColumns: this.resizableColumns,
reorderableColumns: this.reorderableColumns,
+ sortableMembers: this.sortable ? this.collectionState.window?.sortableMembers ?? [] : [],
+ sortCriterion: this.sortCriterion,
+ sortCallback: member => this.#changeSort(member),
rangeProvider: request => this.#projectRange(request, revision)
};
+ this.#restoreCriteriaFocus();
}
async #projectRange(request, revision) {
@@ -595,6 +664,69 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
return Object.freeze({rows: projection.rows});
}
+ #renderCriteriaControls() {
+ const window = this.collectionState.window;
+ if (!this.filterable || window?.searchSupported !== true) return '';
+ const prompt = window.searchPrompt || 'Search collection';
+ return `<div class="causeway-collection-search" role="search">
+ <label for="${this.searchId}">${escapeHtml(prompt)}</label>
+ <input id="${this.searchId}" type="search" maxlength="${MAX_COLLECTION_SEARCH_LENGTH}" value="${escapeHtml(this.searchText)}" data-causeway-collection-search autocomplete="off">
+ <button type="button" data-causeway-collection-search-clear${this.searchText ? '' : ' disabled aria-disabled="true"'}>Clear</button>
+ </div>`;
+ }
+
+ #changeSort(member) {
+ const sortableMembers = new Set(this.collectionState.window?.sortableMembers ?? []);
+ if (!this.sortable || !sortableMembers.has(member) || !this._columns.some(column => column.member === member)) {
+ return false;
+ }
+ const current = this.sortCriterion?.member === member ? this.sortCriterion.direction : null;
+ this.sortCriterion = current === 'ASCENDING'
+ ? Object.freeze({member, direction: 'DESCENDING'})
+ : current === 'DESCENDING' ? null : Object.freeze({member, direction: 'ASCENDING'});
+ this.criteriaFocusIntent = member;
+ this.#reloadCriteria();
+ return true;
+ }
+
+ #commitSearch(value) {
+ const normalized = String(value ?? '').trim().slice(0, MAX_COLLECTION_SEARCH_LENGTH);
+ if (!this.filterable || this.collectionState.window?.searchSupported !== true || normalized === this.searchText) {
+ return false;
+ }
+ this.searchText = normalized;
+ this.criteriaFocusIntent = 'search';
+ this.#reloadCriteria();
+ return true;
+ }
+
+ #reloadCriteria() {
+ this.gridHostRevision += 1;
+ this.loadAbortController?.abort();
+ this.loadAbortController = null;
+ this.#disconnectRangeBroker();
+ if (this.active && this.componentState?.status === 'ready') {
+ void this.load({force: true, offset: 0, size: this.paged ?? this.collectionState.window?.requestedSize});
+ } else if (this.componentState) {
+ this.renderComponentState(this.componentState);
+ }
+ }
+
+ #restoreCriteriaFocus() {
+ const intent = this.criteriaFocusIntent;
+ if (!intent || !this.querySelector) return;
+ const target = intent === 'search'
+ ? this.querySelector('[data-causeway-collection-search]')
+ : this.querySelector(`[data-causeway-collection-sort="${intent}"]`);
+ if (!target) return;
+ queueMicrotask(() => {
+ if (!this.isConnected || this.criteriaFocusIntent !== intent) return;
+ target.focus?.();
+ if (intent === 'search') target.setSelectionRange?.(target.value.length, target.value.length);
+ this.criteriaFocusIntent = null;
+ });
+ }
+
#renderBoundedPager(window) {
if (!window) return '';
const range = window.rangeStart == null
@@ -622,7 +754,7 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
}
#renderTable(rows) {
- const header = `<th scope="col">Item</th>${this._columns.map(column => `<th scope="col"${column.testId ? ` data-testid="${escapeHtml(column.testId)}"` : ''}>${escapeHtml(column.label || humanize(column.member))}</th>`).join('')}`;
+ const header = `<th scope="col">Item</th>${this._columns.map(column => this.#renderColumnHeader(column)).join('')}`;
const body = rows.map((row, rowIndex) => {
const metadata = row?._meta ?? {};
const key = gridRowKey(row);
@@ -633,6 +765,23 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
return `<table class="causeway-collection-table"><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
}
+ #renderColumnHeader(column) {
+ const label = column.label || humanize(column.member);
+ const sortable = this.sortable
+ && this.collectionState.window?.sortableMembers?.includes?.(column.member);
+ const currentDirection = this.sortCriterion?.member === column.member
+ ? this.sortCriterion.direction
+ : null;
+ const ariaSort = currentDirection === 'ASCENDING'
+ ? 'ascending'
+ : currentDirection === 'DESCENDING' ? 'descending' : 'none';
+ const direction = currentDirection === 'ASCENDING' ? ' ↑' : currentDirection === 'DESCENDING' ? ' ↓' : '';
+ const testId = column.testId ? ` data-testid="${escapeHtml(column.testId)}"` : '';
+ return sortable
+ ? `<th scope="col" aria-sort="${ariaSort}"${testId}><button type="button" data-causeway-collection-sort="${escapeHtml(column.member)}" aria-label="Sort ${escapeHtml(label)}${currentDirection === 'ASCENDING' ? ' descending' : currentDirection === 'DESCENDING' ? ' off' : ' ascending'}">${escapeHtml(label)}<span aria-hidden="true">${direction}</span></button></th>`
+ : `<th scope="col"${testId}>${escapeHtml(label)}</th>`;
+ }
+
#renderCell(row, rowIndex, column) {
const property = row?.[column.member] ?? null;
const errors = (this.collectionState.errors ?? []).filter(error => {
@@ -669,6 +818,9 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
|| !Number.isSafeInteger(maximumRetainedRows)) return;
const member = this.id;
const columns = this.columns;
+ const sortBy = this.sortable ? this.sortCriterion?.member ?? null : null;
+ const sortDirection = this.sortable ? this.sortCriterion?.direction ?? 'ASCENDING' : 'ASCENDING';
+ const search = this.filterable ? this.searchText : null;
const broker = new CausewayCollectionRangeBroker({
maximumSize: window.maximumSize,
defaultSize: window.requestedSize,
@@ -681,6 +833,9 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
columns,
offset,
size,
+ sortBy,
+ sortDirection,
+ search,
requestKey,
cache: false,
signal
@@ -888,6 +1043,8 @@ function gridRowKey(row) {
}
const MAX_DECLARATIVE_PAGE_SIZE = 100;
+const MAX_COLLECTION_SEARCH_LENGTH = 256;
+const COLLECTION_SEARCH_DEBOUNCE_MS = 250;
function normalizedPageSize(value) {
const normalized = String(value ?? '').trim();
@@ -902,5 +1059,11 @@ function sameWindowContract(authoritative, candidate) {
if (!authoritative || !candidate) return false;
return authoritative.ordering === candidate.ordering
&& authoritative.totalCount === candidate.totalCount
- && authoritative.maximumSize === candidate.maximumSize;
+ && authoritative.maximumSize === candidate.maximumSize
+ && authoritative.searchSupported === candidate.searchSupported
+ && sameMembers(authoritative.sortableMembers, candidate.sortableMembers);
+}
+
+function sameMembers(left, right) {
+ return JSON.stringify(left ?? []) === JSON.stringify(right ?? []);
}
diff --git a/viewers/webcomponents/foundation/src/collection-grid-qualification.mjs b/viewers/webcomponents/foundation/src/collection-grid-qualification.mjs
index c75c3772fa1..ac38a2ec568 100644
--- a/viewers/webcomponents/foundation/src/collection-grid-qualification.mjs
+++ b/viewers/webcomponents/foundation/src/collection-grid-qualification.mjs
@@ -10,7 +10,7 @@ export const CausewayCollectionPresentation = Object.freeze({
GRID_BOUNDED: 'grid-bounded'
});
-const DETERMINISTIC_ORDERING = 'CONFIGURED';
+const DETERMINISTIC_ORDERINGS = new Set(['CONFIGURED', 'REQUESTED']);
export function qualifyCausewayCollectionGrid(candidate = {}) {
const window = candidate.window ?? null;
@@ -29,7 +29,7 @@ export function qualifyCausewayCollectionGrid(candidate = {}) {
if (candidate.wide !== true) return native(base, 'narrow');
if (candidate.ready !== true) return native(base, 'not-ready');
if (!validWindow(window, rows)) return native(base, 'window-unavailable');
- if (ordering !== DETERMINISTIC_ORDERING) return native(base, 'ordering-not-deterministic');
+ if (!DETERMINISTIC_ORDERINGS.has(ordering)) return native(base, 'ordering-not-deterministic');
if (count === 'invalid') return native(base, 'total-invalid');
if (count === 'zero' || terminalEmptyFirstWindow(window, rows)) return native(base, 'empty');
if (candidate.columnsSupported !== true || candidate.hasVisibleColumn !== true) {
diff --git a/viewers/webcomponents/foundation/src/component-styles.css b/viewers/webcomponents/foundation/src/component-styles.css
index d2c50dfdef8..0991eb46b89 100644
--- a/viewers/webcomponents/foundation/src/component-styles.css
+++ b/viewers/webcomponents/foundation/src/component-styles.css
@@ -477,6 +477,29 @@ cw-collection-grid {
.causeway-collection-content {
min-inline-size: 0;
}
+.causeway-collection-search {
+ align-items: end;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--causeway-collection-search-gap, 0.5rem);
+ margin-block-end: var(--causeway-collection-search-margin, 0.75rem);
+}
+.causeway-collection-search label {
+ font-weight: 600;
+}
+.causeway-collection-search input {
+ max-inline-size: 24rem;
+ min-inline-size: min(16rem, 100%);
+}
+.causeway-collection-table th button[data-causeway-collection-sort] {
+ background: transparent;
+ border: 0;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ font-weight: inherit;
+ padding: 0;
+}
.causeway-collection-pager {
align-items: center;
display: flex;
diff --git a/viewers/webcomponents/foundation/src/component-styles.mjs b/viewers/webcomponents/foundation/src/component-styles.mjs
index b55299567bc..405c7ec8919 100644
--- a/viewers/webcomponents/foundation/src/component-styles.mjs
+++ b/viewers/webcomponents/foundation/src/component-styles.mjs
@@ -478,6 +478,29 @@ cw-collection-grid {
.causeway-collection-content {
min-inline-size: 0;
}
+.causeway-collection-search {
+ align-items: end;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--causeway-collection-search-gap, 0.5rem);
+ margin-block-end: var(--causeway-collection-search-margin, 0.75rem);
+}
+.causeway-collection-search label {
+ font-weight: 600;
+}
+.causeway-collection-search input {
+ max-inline-size: 24rem;
+ min-inline-size: min(16rem, 100%);
+}
+.causeway-collection-table th button[data-causeway-collection-sort] {
+ background: transparent;
+ border: 0;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ font-weight: inherit;
+ padding: 0;
+}
.causeway-collection-pager {
align-items: center;
display: flex;
diff --git a/viewers/webcomponents/foundation/src/graphql-client.mjs b/viewers/webcomponents/foundation/src/graphql-client.mjs
index 676398d6579..adae3447319 100644
--- a/viewers/webcomponents/foundation/src/graphql-client.mjs
+++ b/viewers/webcomponents/foundation/src/graphql-client.mjs
@@ -185,7 +185,18 @@ export class CausewayGraphQLClient {
});
}
- async readCollectionWindow({description, identity, member, rowSelection, offset, size, signal}) {
+ async readCollectionWindow({
+ description,
+ identity,
+ member,
+ rowSelection,
+ offset,
+ size,
+ sortBy = null,
+ sortDirection = 'ASCENDING',
+ search = null,
+ signal
+ }) {
const operation = buildCollectionWindowReadOperation({
description,
identity,
@@ -193,6 +204,9 @@ export class CausewayGraphQLClient {
rowSelection,
offset,
size,
+ sortBy,
+ sortDirection,
+ search,
schemaNames: this.schemaNames
});
const response = await this.executor({
@@ -508,12 +522,18 @@ function semanticCollectionWindowDescription(kind, supportType, describedTypes)
return null;
}
const args = new Map(field.args.map(argument => [argument.name, argument]));
+ const fields = fieldsByName(typeDescription);
+ const sortDirectionType = namedType(args.get('sortDirection')?.type);
return Object.freeze({
generatedTypeName,
typeDescription,
- fields: fieldsByName(typeDescription),
+ fields,
offsetDefault: integerDefault(args.get('offset')?.defaultValue, 0),
- sizeDefault: integerDefault(args.get('size')?.defaultValue, null)
+ sizeDefault: integerDefault(args.get('size')?.defaultValue, null),
+ sortSupported: args.has('sortBy') && args.has('sortDirection')
+ && Boolean(sortDirectionType) && fields.has('sortableMembers'),
+ sortDirectionType,
+ searchSupported: args.has('search') && fields.has('searchSupported')
});
}
diff --git a/viewers/webcomponents/foundation/src/grid-widget.mjs b/viewers/webcomponents/foundation/src/grid-widget.mjs
index e1d3202117a..2016f07edc4 100644
--- a/viewers/webcomponents/foundation/src/grid-widget.mjs
+++ b/viewers/webcomponents/foundation/src/grid-widget.mjs
@@ -65,6 +65,7 @@ export class CausewayCollectionGridElement extends HTMLElement {
this._presentation = null;
this._focusRequested = false;
this._semanticFocusIntent = null;
+ this._sortFocusIntent = null;
this._policyListener = () => this.#applyCurrentPolicy();
}
@@ -108,6 +109,13 @@ export class CausewayCollectionGridElement extends HTMLElement {
this._control?.focus?.(options);
}
+ restoreSortFocus(member) {
+ if (!member) return false;
+ this._sortFocusIntent = String(member);
+ this._control?.requestContentUpdate?.();
+ return true;
+ }
+
restoreSemanticFocus(intent) {
if (!intent?.rowKey || !intent?.member) return false;
this._semanticFocusIntent = Object.freeze({
@@ -183,6 +191,34 @@ export class CausewayCollectionGridElement extends HTMLElement {
for (const descriptor of presentation.columns) {
const column = document.createElement('vaadin-grid-column');
column.header = descriptor.label;
+ const sortable = presentation.sortableMembers.has(descriptor.member);
+ const currentDirection = presentation.sortCriterion
+ && presentation.sortCriterion.member === descriptor.member
+ ? presentation.sortCriterion.direction
+ : null;
+ if (sortable) {
+ column.headerRenderer = root => {
+ if (presentationRevision !== this._presentationRevision || control !== this._control) return;
+ root.replaceChildren();
+ root.parentElement?.setAttribute?.('aria-sort', currentDirection === 'ASCENDING'
+ ? 'ascending'
+ : currentDirection === 'DESCENDING' ? 'descending' : 'none');
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.dataset.causewayCollectionSort = descriptor.member;
+ button.textContent = `${descriptor.label}${currentDirection === 'ASCENDING' ? ' ↑' : currentDirection === 'DESCENDING' ? ' ↓' : ''}`;
+ button.setAttribute('aria-label', `Sort ${descriptor.label}${currentDirection === 'ASCENDING' ? ' descending' : currentDirection === 'DESCENDING' ? ' off' : ' ascending'}`);
+ button.addEventListener('click', event => {
+ event.stopPropagation();
+ presentation.sortCallback(descriptor.member);
+ });
+ root.appendChild(button);
+ if (this._sortFocusIntent === descriptor.member) {
+ this._sortFocusIntent = null;
+ queueMicrotask(() => button.focus?.());
+ }
+ };
+ }
column.resizable = presentation.resizableColumns;
column.sortable = false;
column.frozen = false;
@@ -254,6 +290,12 @@ function freezePresentation(value = {}) {
? value.totalCount
: rows.length;
const pageSize = Number.isSafeInteger(value.pageSize) && value.pageSize > 0 ? value.pageSize : Math.max(rows.length, 1);
+ const sortableMembers = new Set((Array.isArray(value.sortableMembers) ? value.sortableMembers : [])
+ .filter(member => typeof member === 'string'));
+ const sortCriterion = value.sortCriterion?.member
+ && ['ASCENDING', 'DESCENDING'].includes(value.sortCriterion.direction)
+ ? Object.freeze({member: String(value.sortCriterion.member), direction: value.sortCriterion.direction})
+ : null;
return Object.freeze({
mode,
rows,
@@ -265,7 +307,10 @@ function freezePresentation(value = {}) {
describedBy: String(value.describedBy ?? ''),
testId: String(value.testId ?? ''),
resizableColumns: value.resizableColumns === true,
- reorderableColumns: value.reorderableColumns === true
+ reorderableColumns: value.reorderableColumns === true,
+ sortableMembers,
+ sortCriterion,
+ sortCallback: typeof value.sortCallback === 'function' ? value.sortCallback : () => {}
});
}
diff --git a/viewers/webcomponents/foundation/src/object-context-controller.mjs b/viewers/webcomponents/foundation/src/object-context-controller.mjs
index 10e7ae474ce..d49151856c1 100644
--- a/viewers/webcomponents/foundation/src/object-context-controller.mjs
+++ b/viewers/webcomponents/foundation/src/object-context-controller.mjs
@@ -44,6 +44,7 @@ import {
mergeSelections,
selectionForRuntimeType
} from './selection.mjs';
+import {assertGraphQLName} from './schema-names.mjs';
import {fetchStructuralResource, StructuralResourceError} from './structural-resource.mjs';
import {InteractionResultKind, InteractionStatus, ObjectContextStatus, RequirementStatus} from './types.mjs';
@@ -600,6 +601,9 @@ export class ObjectContextController extends EventTarget {
columns = [],
offset = 0,
size = null,
+ sortBy = null,
+ sortDirection = 'ASCENDING',
+ search = null,
requestKey = null,
force = false,
cache = true,
@@ -620,10 +624,28 @@ export class ObjectContextController extends EventTarget {
const requestedSize = usesWindow
? integerAtLeast(size ?? descriptor.window.sizeDefault, 1, 'Collection window size')
: null;
+ const requestedSortBy = usesWindow && descriptor.window.sortSupported
+ ? optionalGraphQLName(sortBy, 'Collection sort member')
+ : null;
+ const requestedSortDirection = requestedSortBy
+ ? collectionSortDirection(sortDirection)
+ : 'ASCENDING';
+ const requestedSearch = usesWindow && descriptor.window.searchSupported
+ ? boundedSearch(search)
+ : null;
let rowSelection = abstractRows
? {__typename: true}
: collectionRowSelection(descriptor, columns, description.types);
- const cacheKey = JSON.stringify({member, columns, offset: requestedOffset, size: requestedSize, usesWindow});
+ const cacheKey = JSON.stringify({
+ member,
+ columns,
+ offset: requestedOffset,
+ size: requestedSize,
+ sortBy: requestedSortBy,
+ sortDirection: requestedSortDirection,
+ search: requestedSearch,
+ usesWindow
+ });
if (!force && cache && this.secondaryCache.has(cacheKey)) {
const cached = this.secondaryCache.get(cacheKey);
if (!cached.abortController.signal.aborted) {
@@ -656,6 +678,9 @@ export class ObjectContextController extends EventTarget {
rowSelection: selection,
offset: requestedOffset,
size: requestedSize,
+ sortBy: requestedSortBy,
+ sortDirection: requestedSortDirection,
+ search: requestedSearch,
signal: abortController.signal
})
: this.client.readObject({
@@ -1328,6 +1353,31 @@ function integerAtLeast(value, minimum, label) {
return value;
}
+function optionalGraphQLName(value, label) {
+ if (value == null || String(value).trim() === '') {
+ return null;
+ }
+ return assertGraphQLName(String(value).trim(), label);
+}
+
+function collectionSortDirection(value) {
+ if (value === 'ASCENDING' || value === 'DESCENDING') {
+ return value;
+ }
+ throw new Error('Collection sort direction must be ASCENDING or DESCENDING.');
+}
+
+function boundedSearch(value) {
+ if (value == null || String(value).trim() === '') {
+ return null;
+ }
+ const normalized = String(value).trim();
+ if (normalized.length > 256) {
+ throw new Error('Collection search must not exceed 256 characters.');
+ }
+ return normalized;
+}
+
function normalizeCollectionWindow(window) {
if (!window) {
return null;
@@ -1337,7 +1387,7 @@ function normalizeCollectionWindow(window) {
const returnedCount = window.returnedCount;
const hasPrevious = window.hasPrevious === true;
const hasNext = window.hasNext === true;
- return Object.freeze({
+ const normalized = {
offset,
requestedSize,
returnedCount,
@@ -1351,7 +1401,19 @@ function normalizeCollectionWindow(window) {
rangeStart: returnedCount > 0 ? offset + 1 : null,
rangeEnd: returnedCount > 0 ? offset + returnedCount : null,
ordering: window.ordering ?? null
- });
+ };
+ if (Object.hasOwn(window, 'sortableMembers')) {
+ normalized.sortableMembers = Object.freeze(Array.isArray(window.sortableMembers)
+ ? window.sortableMembers.filter(member => typeof member === 'string')
+ : []);
+ }
+ if (Object.hasOwn(window, 'searchSupported')) {
+ normalized.searchSupported = window.searchSupported === true;
+ }
+ if (Object.hasOwn(window, 'searchPrompt')) {
+ normalized.searchPrompt = typeof window.searchPrompt === 'string' ? window.searchPrompt : null;
+ }
+ return Object.freeze(normalized);
}
function obsoleteRequestError() {
diff --git a/viewers/webcomponents/foundation/src/selection.mjs b/viewers/webcomponents/foundation/src/selection.mjs
index 23adbca5d6b..20d3dbd5bbe 100644
--- a/viewers/webcomponents/foundation/src/selection.mjs
+++ b/viewers/webcomponents/foundation/src/selection.mjs
@@ -168,6 +168,9 @@ export function buildCollectionWindowReadOperation({
rowSelection,
offset,
size,
+ sortBy = null,
+ sortDirection = 'ASCENDING',
+ search = null,
schemaNames
}) {
const objectField = assertGraphQLName(description.generatedFieldName, 'object field');
@@ -180,13 +183,46 @@ export function buildCollectionWindowReadOperation({
rowSelection,
rowIndentation,
{typeDescription: rowType, types: description.types ?? null});
- const windowSelection = `offset\n${rowIndentation.slice(2)}requestedSize\n${rowIndentation.slice(2)}returnedCount\n${rowIndentation.slice(2)}totalCount\n${rowIndentation.slice(2)}maximumSize\n${rowIndentation.slice(2)}hasPrevious\n${rowIndentation.slice(2)}hasNext\n${rowIndentation.slice(2)}ordering\n${rowIndentation.slice(2)}rows {\n${renderedRows}\n${rowIndentation.slice(2)}}`;
+ const windowFields = collection?.window?.fields ?? new Map();
+ const sortSupported = collection?.window?.sortSupported === true;
+ const searchSupported = collection?.window?.searchSupported === true;
+ const metadataFields = [
+ sortSupported && windowFields.has('sortableMembers') ? 'sortableMembers' : null,
+ searchSupported && windowFields.has('searchSupported') ? 'searchSupported' : null,
+ searchSupported && windowFields.has('searchPrompt') ? 'searchPrompt' : null
+ ].filter(Boolean);
+ const baseFields = [
+ 'offset', 'requestedSize', 'returnedCount', 'totalCount', 'maximumSize',
+ 'hasPrevious', 'hasNext', 'ordering', ...metadataFields
+ ];
+ const selectionIndentation = rowIndentation.slice(2);
+ const windowSelection = `${baseFields.join(`\n${selectionIndentation}`)}\n${selectionIndentation}rows {\n${renderedRows}\n${selectionIndentation}}`;
+ const variableDefinitions = [
+ `$object: ${description.generatedInputTypeName}!`, '$offset: Int!', '$size: Int!'
+ ];
+ const windowArguments = ['offset: $offset', 'size: $size'];
+ const variables = {object: {id: identity.id}, offset, size};
+ if (sortSupported) {
+ const sortDirectionType = assertGraphQLName(
+ collection.window.sortDirectionType,
+ 'collection sort direction type');
+ variableDefinitions.push('$sortBy: String', `$sortDirection: ${sortDirectionType}!`);
+ windowArguments.push('sortBy: $sortBy', 'sortDirection: $sortDirection');
+ variables.sortBy = sortBy;
+ variables.sortDirection = sortDirection;
+ }
+ if (searchSupported) {
+ variableDefinitions.push('$search: String');
+ windowArguments.push('search: $search');
+ variables.search = search;
+ }
+ const windowCall = `window(${windowArguments.join(', ')})`;
const objectRead = schemaNames.richRootField
- ? ` ${assertGraphQLName(schemaNames.richRootField, 'rich root field')} {\n ${objectField}(${lookupArgument}: $object) {\n ${collectionField} {\n window(offset: $offset, size: $size) {\n ${windowSelection}\n }\n }\n }\n }`
- : ` ${objectField}(${lookupArgument}: $object) {\n ${collectionField} {\n window(offset: $offset, size: $size) {\n ${windowSelection}\n }\n }\n }`;
+ ? ` ${assertGraphQLName(schemaNames.richRootField, 'rich root field')} {\n ${objectField}(${lookupArgument}: $object) {\n ${collectionField} {\n ${windowCall} {\n ${windowSelection}\n }\n }\n }\n }`
+ : ` ${objectField}(${lookupArgument}: $object) {\n ${collectionField} {\n ${windowCall} {\n ${windowSelection}\n }\n }\n }`;
return Object.freeze({
- document: `query CausewayReadCollectionWindow($object: ${description.generatedInputTypeName}!, $offset: Int!, $size: Int!) {\n${objectRead}\n}`,
- variables: {object: {id: identity.id}, offset, size},
+ document: `query CausewayReadCollectionWindow(${variableDefinitions.join(', ')}) {\n${objectRead}\n}`,
+ variables,
operationName: 'CausewayReadCollectionWindow',
objectPath: schemaNames.richRootField
? [schemaNames.richRootField, objectField]
diff --git a/viewers/webcomponents/foundation/test/collection-grid-qualification.test.mjs b/viewers/webcomponents/foundation/test/collection-grid-qualification.test.mjs
index 27147e05302..4fbc3e087f2 100644
--- a/viewers/webcomponents/foundation/test/collection-grid-qualification.test.mjs
+++ b/viewers/webcomponents/foundation/test/collection-grid-qualification.test.mjs
@@ -56,6 +56,15 @@ test('stable positive total and deterministic ordering qualify virtual Grid', ()
assert.equal(Object.isFrozen(result.lifecycle), true);
});
+test('requested server sorting is deterministic for Grid qualification', () => {
+ const result = qualifyCausewayCollectionGrid(candidate({
+ window: {...candidate().window, ordering: 'REQUESTED'}
+ }));
+ assert.equal(result.qualified, true);
+ assert.equal(result.presentation, CausewayCollectionPresentation.GRID_VIRTUAL);
+ assert.equal(result.ordering, 'REQUESTED');
+});
+
test('explicit bounded paging suppresses virtual Grid even when total count is available', () => {
const result = qualifyCausewayCollectionGrid(candidate({bounded: true}));
assert.equal(result.qualified, true);
diff --git a/viewers/webcomponents/foundation/test/collection.test.mjs b/viewers/webcomponents/foundation/test/collection.test.mjs
index 304891a3cce..9121794b1f2 100644
--- a/viewers/webcomponents/foundation/test/collection.test.mjs
+++ b/viewers/webcomponents/foundation/test/collection.test.mjs
@@ -22,6 +22,7 @@ import test from 'node:test';
import {installDomShim} from './dom-shim.mjs';
import {
collectionWindowResponse,
+ createCriteriaWindowedRichSchemaTypes,
createRichSchemaFixtureExecutor,
createRichSchemaTypes,
createVersionlessRichSchemaTypes,
@@ -158,6 +159,56 @@ test('collection secondary reads are lazy, cached and hydrate row contexts', asy
assert.equal(codeState.data.get, 'ADA');
});
+test('collection window criteria are discovered, transported and included in request identity', async () => {
+ const rows = [row({id: 'staff-2', name: 'Dr Grace'})];
+ const executor = createRichSchemaFixtureExecutor({
+ types: createCriteriaWindowedRichSchemaTypes(),
+ readResponses: [graphQLObjectResponse()],
+ windowResponses: [collectionWindowResponse({
+ rows,
+ requestedSize: 2,
+ totalCount: 1,
+ ordering: 'REQUESTED',
+ sortableMembers: ['name'],
+ searchSupported: true,
+ searchPrompt: 'Search staff member'
+ })]
+ });
+ const context = new ObjectContextController({
+ client: new CausewayGraphQLClient({executor}),
+ logicalTypeName: DEPARTMENT_LOGICAL_TYPE,
+ objectId: '42'
+ });
+ context.registerRequirement({kind: 'collection', member: 'staffMembers'});
+ await waitFor(() => context.state.status === 'ready');
+
+ const loaded = await context.loadCollection({
+ member: 'staffMembers',
+ columns: ['name'],
+ offset: 0,
+ size: 2,
+ sortBy: 'name',
+ sortDirection: 'DESCENDING',
+ search: 'Grace'
+ });
+
+ assert.deepEqual(executor.windowCalls[0].variables, {
+ object: {id: '42'},
+ offset: 0,
+ size: 2,
+ sortBy: 'name',
+ sortDirection: 'DESCENDING',
+ search: 'Grace'
+ });
+ assert.match(executor.windowCalls[0].document, /sortBy: \$sortBy/);
+ assert.match(executor.windowCalls[0].document, /search: \$search/);
+ assert.match(executor.windowCalls[0].document, /sortableMembers/);
+ assert.deepEqual(loaded.window.sortableMembers, ['name']);
+ assert.equal(loaded.window.searchSupported, true);
+ assert.equal(loaded.window.searchPrompt, 'Search staff member');
+ assert.equal(loaded.window.ordering, 'REQUESTED');
+});
+
test('versionless concrete collection rows retain identity, columns and hydration', async () => {
const staff = row({version: null});
const executor = createRichSchemaFixtureExecutor({
@@ -593,6 +644,8 @@ test('collection component resolves canonical and HTML headings without unmodifi
'label',
'active',
'paged',
+ 'sortable',
+ 'filterable',
'resizable-columns',
'reorderable-columns'
]);
@@ -768,6 +821,99 @@ test('collection component forwards window requests and publishes semantic windo
document.body.removeChild(collection);
});
+test('collection host owns server-backed sort and search criteria across native reloads', async () => {
+ const requests = [];
+ const context = {
+ registerRequirement(_requirement, listener) {
+ listener({
+ status: 'ready',
+ descriptor: {id: 'staffMembers'},
+ data: {hidden: false, disabled: null},
+ errors: [],
+ generation: 1
+ });
+ return () => {};
+ },
+ async loadCollection(options) {
+ requests.push(options);
+ const currentRows = [row({id: `staff-${requests.length}`, name: `Dr ${requests.length}`})];
+ return {
+ descriptor: {id: 'staffMembers'},
+ data: {window: {rows: currentRows}},
+ rows: currentRows,
+ window: {
+ offset: 0,
+ requestedSize: 2,
+ returnedCount: 1,
+ totalCount: 1,
+ countAvailable: true,
+ maximumSize: 100,
+ hasPrevious: false,
+ hasNext: false,
+ previousOffset: null,
+ nextOffset: null,
+ rangeStart: 1,
+ rangeEnd: 1,
+ ordering: options.sortBy ? 'REQUESTED' : 'ENCOUNTER',
+ sortableMembers: ['name'],
+ searchSupported: true,
+ searchPrompt: 'Search owners'
+ },
+ errors: [],
+ rowDescription: {members: new Map([['name', {value: {typeRef: {kind: 'SCALAR', name: 'String'}}}]])},
+ rowSelection: {_meta: {id: true}, name: {get: true}}
+ };
+ },
+ createHydratedRowContext() {
+ return {disconnect() {}};
+ }
+ };
+ const collection = new CausewayCollectionElement();
+ collection.id = 'staffMembers';
+ collection.columns = [{member: 'name', label: 'Name'}];
+ collection.sortable = true;
+ collection.filterable = true;
+ collection.paged = 2;
+ collection.active = true;
+ collection.context = context;
+ document.body.appendChild(collection);
+ await waitFor(() => collection.collectionState.status === 'ready');
+
+ assert.match(collection.innerHTML, /data-causeway-collection-sort="name"/);
+ assert.match(collection.innerHTML, /Search owners/);
+ assert.equal(requests[0].sortBy, null);
+ assert.equal(requests[0].search, '');
+
+ const sortButton = document.createElement('button');
+ sortButton.setAttribute('data-causeway-collection-sort', 'name');
+ collection.appendChild(sortButton);
+ sortButton.dispatchEvent(new Event('click', {bubbles: true}));
+ await waitFor(() => requests.length === 2 && collection.collectionState.status === 'ready');
+ assert.equal(requests[1].offset, 0);
+ assert.equal(requests[1].sortBy, 'name');
+ assert.equal(requests[1].sortDirection, 'ASCENDING');
+
+ sortButton.dispatchEvent(new Event('click', {bubbles: true}));
+ await waitFor(() => requests.length === 3 && collection.collectionState.status === 'ready');
+ assert.equal(requests[2].sortDirection, 'DESCENDING');
+
+ const search = document.createElement('input');
+ search.setAttribute('data-causeway-collection-search', '');
+ search.value = ' Ada ';
+ collection.appendChild(search);
+ search.dispatchEvent(new Event('input', {bubbles: true}));
+ await waitFor(() => requests.length === 4 && collection.collectionState.status === 'ready', 1000);
+ assert.equal(requests[3].offset, 0);
+ assert.equal(requests[3].search, 'Ada');
+ assert.equal(requests[3].sortDirection, 'DESCENDING');
+
+ collection.filterable = false;
+ await waitFor(() => requests.length === 5 && collection.collectionState.status === 'ready');
+ assert.equal(requests[4].search, null);
+ assert.doesNotMatch(collection.innerHTML, /data-causeway-collection-search/);
+ document.body.removeChild(collection);
+});
+
test('collection host owns immutable Grid qualification diagnostics policy recovery and bounded ranges', async () => {
configureCausewayGridWidgets({enabled: true});
const rangeRequests = [];
diff --git a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
index f16a1d748c8..440c549f744 100644
--- a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
+++ b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
@@ -35,6 +35,7 @@ const UNSUPPORTED_GET_TYPE = `${UNSUPPORTED_TYPE}__gqlv_get`;
const COLLECTION_TYPE = `${OBJECT_TYPE}__staffMembers__gqlv_collection`;
const COLLECTION_WINDOW_TYPE = `${COLLECTION_TYPE}_window`;
const COLLECTION_WINDOW_ORDERING_TYPE = 'rich__gqlv_collection_window_ordering';
+const COLLECTION_WINDOW_SORT_DIRECTION_TYPE = 'rich__gqlv_collection_window_sort_direction';
const EMPTY_COLLECTION_TYPE = `${OBJECT_TYPE}__formerStaff__gqlv_collection`;
const ACTION_TYPE = `${OBJECT_TYPE}__changeName__gqlv_action`;
const ACTION_PARAMS_TYPE = `${OBJECT_TYPE}__changeName__gqlv_action_params`;
@@ -200,6 +201,48 @@ export function createAutoCompleteWindowRichSchemaTypes() {
return types;
}
+export function createCriteriaWindowedRichSchemaTypes() {
+ const types = createWindowedRichSchemaTypes();
+ const collectionType = types.get(COLLECTION_TYPE);
+ types.set(COLLECTION_TYPE, objectType(COLLECTION_TYPE, null, collectionType.fields.map(candidate =>
+ candidate.name === 'window'
+ ? field(candidate.name, candidate.description, candidate.type, [
+ ...candidate.args,
+ argument('sortBy', scalar('String')),
+ argument('sortDirection', nonNull(enumeration(COLLECTION_WINDOW_SORT_DIRECTION_TYPE)), {defaultValue: 'ASCENDING'}),
+ argument('search', scalar('String'))
+ ])
+ : candidate)));
+ const windowType = types.get(COLLECTION_WINDOW_TYPE);
+ types.set(COLLECTION_WINDOW_TYPE, objectType(COLLECTION_WINDOW_TYPE, windowType.description, [
+ ...windowType.fields,
+ field('sortableMembers', null, nonNull(list(nonNull(scalar('String'))))),
+ field('searchSupported', null, nonNull(scalar('Boolean'))),
+ field('searchPrompt', null, scalar('String'))
+ ]));
+ types.set(COLLECTION_WINDOW_ORDERING_TYPE, {
+ ...types.get(COLLECTION_WINDOW_ORDERING_TYPE),
+ enumValues: [
+ {name: 'CONFIGURED', description: 'A Causeway configured comparator was applied.'},
+ {name: 'REQUESTED', description: 'A requested accepted sort was applied.'},
+ {name: 'ENCOUNTER', description: 'Encounter order was retained.'}
+ ]
+ });
+ types.set(COLLECTION_WINDOW_SORT_DIRECTION_TYPE, {
+ kind: 'ENUM',
+ name: COLLECTION_WINDOW_SORT_DIRECTION_TYPE,
+ description: 'Direction for a requested collection-window sort.',
+ fields: [],
+ inputFields: [],
+ enumValues: [
+ {name: 'ASCENDING', description: null},
+ {name: 'DESCENDING', description: null}
+ ],
+ possibleTypes: []
+ });
+ return types;
+}
+
export function createVersionlessRichSchemaTypes({windowed = false} = {}) {
const types = windowed ? createWindowedRichSchemaTypes() : createRichSchemaTypes();
const metadataType = types.get(STAFF_META_TYPE);
@@ -317,7 +360,16 @@ export function departmentObjectData({
};
}
-export function collectionWindowResponse({rows = [], offset = 0, requestedSize = 20, totalCount = rows.length} = {}) {
+export function collectionWindowResponse({
+ rows = [],
+ offset = 0,
+ requestedSize = 20,
+ totalCount = rows.length,
+ ordering = 'ENCOUNTER',
+ sortableMembers,
+ searchSupported,
+ searchPrompt
+} = {}) {
return {
data: {
rich: {
@@ -332,7 +384,10 @@ export function collectionWindowResponse({rows = [], offset = 0, requestedSize =
maximumSize: 100,
hasPrevious: offset > 0 && totalCount > 0,
hasNext: offset + rows.length < totalCount,
- ordering: 'ENCOUNTER'
+ ordering,
+ ...(sortableMembers === undefined ? {} : {sortableMembers}),
+ ...(searchSupported === undefined ? {} : {searchSupported}),
+ ...(searchPrompt === undefined ? {} : {searchPrompt})
}
}
}
diff --git a/viewers/webcomponents/foundation/test/grid-widget.test.mjs b/viewers/webcomponents/foundation/test/grid-widget.test.mjs
index 73634c0d8b7..d7a00de92e4 100644
--- a/viewers/webcomponents/foundation/test/grid-widget.test.mjs
+++ b/viewers/webcomponents/foundation/test/grid-widget.test.mjs
@@ -120,6 +120,31 @@ test('bounded Grid upgrades lazily with immutable rows columns and relationships
document.body.removeChild(adapter);
});
+test('Grid renders Causeway-owned sort headers without enabling toolkit sorting', async () => {
+ configureCausewayGridWidgets({enabled: true, moduleUrl: fakeGridModule});
+ const requests = [];
+ const adapter = new CausewayCollectionGridElement();
+ adapter.presentation = {
+ ...boundedPresentation(),
+ columns: [{...column, member: 'name'}],
+ sortableMembers: ['name'],
+ sortCriterion: {member: 'name', direction: 'ASCENDING'},
+ sortCallback: member => requests.push(member)
+ };
+ await connect(adapter);
+ const gridColumn = adapter.childNodes[0].childNodes[0];
+ assert.equal(gridColumn.sortable, false);
+ assert.equal(typeof gridColumn.headerRenderer, 'function');
+ const root = document.createElement('div');
+ gridColumn.headerRenderer(root);
+ const button = root.childNodes[0];
+ assert.equal(button.dataset.causewayCollectionSort, 'name');
+ assert.match(button.textContent, /↑/);
+ button.dispatchEvent(new Event('click'));
+ assert.deepEqual(requests, ['name']);
+ document.body.removeChild(adapter);
+});
+
test('virtual Grid maps page callbacks to bounded range provider', async () => {
configureCausewayGridWidgets({enabled: true, moduleUrl: fakeGridModule});
const requests = [];
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwnerCollectionFilterService.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwnerCollectionFilterService.java
new file mode 100644
index 00000000000..2ffccab1a11
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwnerCollectionFilterService.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.function.Function;
+
+import org.springframework.stereotype.Service;
+
+import org.apache.causeway.applib.services.filter.CollectionFilterService;
+import org.apache.causeway.applib.services.i18n.TranslatableString;
+
+@Service
+public class PetOwnerCollectionFilterService implements CollectionFilterService {
+
+ @Override
+ public boolean handles(final Class<?> domainType) {
+ return PetOwner.class.isAssignableFrom(domainType);
+ }
+
+ @Override
+ public <T> Function<T, Tokens> tokenizer(final Class<T> domainType) {
+ return candidate -> {
+ var owner = (PetOwner) candidate;
+ var searchable = String.join(" ",
+ owner.getName(),
+ owner.getKnownAs() == null ? "" : owner.getKnownAs())
+ .toLowerCase(Locale.ROOT);
+ return searchArgument -> Arrays.stream(searchArgument.toLowerCase(Locale.ROOT).split("\\s+"))
+ .allMatch(searchable::contains);
+ };
+ }
+
+ @Override
+ public TranslatableString searchPromptPlaceholderText(final Class<?> domainType) {
+ return TranslatableString.tr("Search owner names");
+ }
+}
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 7fc207a3807..cd7bc20ee35 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
@@ -18,7 +18,7 @@
<div class="petclinic-dashboard-grid">
<section aria-labelledby="petclinic-owner-heading">
<h2 id="petclinic-owner-heading">Pet owners</h2>
- <cw-collection id="petOwners" label="Pet owners" active paged="2">
+ <cw-collection id="petOwners" label="Pet owners" active paged="2" sortable filterable>
<cw-collection-column id="name" label="Owner"></cw-collection-column>
<cw-collection-column id="telephoneNumber" label="Telephone"></cw-collection-column>
<cw-collection-column id="emailAddress" label="Email"></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 1a54b426278..3b3d57300cf 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" named="Companion animals" active>
+ <cw-collection id="pets" named="Companion animals" active sortable>
<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>
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 ff8d71ab8dd..1c1997c0e9c 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
@@ -213,6 +213,21 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
.isEqualTo(nativeToolkit() ? 0 : 1);
assertThat(toolkitRequests.stream().noneMatch(url -> !url.contains("/vaadin-menubar/"))).isTrue();
+ final var ownerSearch = page.locator("cw-collection[id='petOwners'] [data-causeway-collection-search]");
+ assertThat(ownerSearch.isVisible()).isTrue();
+ assertThat(ownerSearch.getAttribute("maxlength")).isEqualTo("256");
+ ownerSearch.fill("Mary");
+ page.waitForFunction("() => document.querySelector(\"cw-collection[id='petOwners']\")?.collectionState?.window?.totalCount === 1");
+ waitForCollectionRows("petOwners", 1);
+ assertThat(page.locator("cw-collection[id='petOwners']").innerText()).contains("Mary Smith");
+ page.locator("cw-collection[id='petOwners'] [data-causeway-collection-search-clear]").click();
+ page.waitForFunction("() => document.querySelector(\"cw-collection[id='petOwners']\")?.collectionState?.window?.totalCount === 4");
+ waitForCollectionRows("petOwners", 2);
+ page.locator("cw-collection[id='petOwners'] [data-causeway-collection-sort='name']").click();
+ page.waitForFunction("() => document.querySelector(\"cw-collection[id='petOwners']\")?.collectionState?.window?.ordering === 'REQUESTED'");
+ assertThat(page.locator("cw-collection[id='petOwners'] [data-causeway-collection-sort='name']").getAttribute("aria-label"))
+ .contains("descending");
+
page.locator("cw-collection[id='petOwners'] [data-causeway-grid-next]").click();
page.waitForFunction("() => document.querySelector(\"cw-collection[id='petOwners']\")?.collectionState?.window?.offset === 2");
waitForCollectionRows("petOwners", 2);
@@ -235,6 +250,10 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
assertFocused(ROUTE_PAGE);
waitForCollectionRows("pets", 2);
waitForCollectionRows("visits", 1);
+ assertThat(page.locator("cw-collection[id='pets']").getAttribute("sortable")).isEmpty();
+ page.locator("cw-collection[id='pets'] [data-causeway-collection-sort='name']").click();
+ page.waitForFunction("() => document.querySelector(\"cw-collection[id='pets']\")?.collectionState?.window?.ordering === 'REQUESTED'");
+ assertThat(page.locator("cw-collection[id='pets'] cw-action[id='addPet']").isVisible()).isTrue();
assertThat(page.locator("cw-collection[id='visits']").getAttribute("paged")).isEqualTo("1");
assertThat(page.locator("cw-collection[id='visits'] [data-causeway-grid-next]").isVisible()).isTrue();
page.locator("cw-collection[id='visits'] [data-causeway-grid-next]").click();
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 3a15898aca8..2fc8d029c49 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,7 +216,7 @@ 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\" named=\"Companion animals\" active>")
+ .contains("<cw-collection id=\"pets\" named=\"Companion animals\" active sortable>")
.contains("<cw-action id=\"addPet\"")
.contains("<cw-action id=\"removePet\"")
.contains("<cw-collection id=\"visits\" named=\"Visit history\"")
@@ -231,7 +231,7 @@ void packagesHtmlPagesAndRetainsLayoutFallbackResources() throws Exception {
}
assertThat(homeHtml)
.contains("<cw-collection id=\"futureVisits\" named=\"Next appointments\" active>")
- .contains("<cw-collection id=\"petOwners\" label=\"Pet owners\" active paged=\"2\">")
+ .contains("<cw-collection id=\"petOwners\" label=\"Pet owners\" active paged=\"2\" sortable filterable>")
.doesNotContain("id=\"petOwners\" named=", "id=\"petOwners\" described-as=", " offset=", " size=");
assertThat(ownerHtml.indexOf("id=\"addPet\""))
.isLessThan(ownerHtml.indexOf("id=\"removePet\""));
@@ -277,6 +277,32 @@ void exposesObjectHomeEffectiveMenusAndEffectiveGrid() throws Exception {
assertThat(application.at("/data/rich/application/home/logicalTypeName").asText()).isEqualTo("petclinic.HomePage");
assertThat(application.at("/data/rich/application/home/object/_meta/title").asText())
.contains("4 pet owners", "3 upcoming visits");
+ final var homeId = application.at("/data/rich/application/home/object/_meta/id").asText();
+ final var ownerWindow = graphQL("""
+ query PetClinicOwnerWindow {
+ rich {
+ petclinic_HomePage(object: {id: "%s"}) {
+ petOwners {
+ window(search: "mary", sortBy: "name", sortDirection: DESCENDING, size: 2) {
+ totalCount ordering sortableMembers searchSupported searchPrompt
+ rows { name { get } }
+ }
+ }
+ }
+ }
+ }
+ """.formatted(homeId));
+ assertNoGraphQLErrors(ownerWindow);
+ assertThat(ownerWindow.at("/data/rich/petclinic_HomePage/petOwners/window/totalCount").asInt()).isEqualTo(1);
+ assertThat(ownerWindow.at("/data/rich/petclinic_HomePage/petOwners/window/ordering").asText())
+ .isEqualTo("REQUESTED");
+ assertThat(ownerWindow.at("/data/rich/petclinic_HomePage/petOwners/window/searchSupported").asBoolean()).isTrue();
+ assertThat(ownerWindow.at("/data/rich/petclinic_HomePage/petOwners/window/searchPrompt").asText())
+ .isEqualTo("Search owner names");
+ assertThat(ownerWindow.at("/data/rich/petclinic_HomePage/petOwners/window/sortableMembers").toString())
+ .contains("\"name\"");
+ assertThat(ownerWindow.at("/data/rich/petclinic_HomePage/petOwners/window/rows/0/name/get").asText())
+ .isEqualTo("Mary Smith");
final var menuHref = application.at("/data/rich/application/menuBars/href").asText();
final var menuBars = get(menuHref);