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/23 09:16:56 UTC
(causeway) 21/22: hardens web component input value semantics
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 42d17355e42df5a9d79a0a65b394abcd18ccc3da
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sun Aug 23 10:03:41 2026 +0100
hardens web component input value semantics
---
.../evidence/csp-harness/pilot-server.mjs | 15 +-
.../evidence/REPRODUCING.md | 73 ++++
.../evidence/final-gate-results.md | 22 ++
.../evidence/input-capability-inventory.md | 47 +++
.../evidence/production-isolation.md | 18 +
.../evidence/value-semantics-contract.md | 58 ++++
.../tasks.md | 80 ++---
.../htmx/ReferenceAppHtmxPlaywrightTest.java | 24 ++
.../ReferenceAppCapabilityInventory_IntegTest.java | 4 +-
.../ReferenceAppHtmxApplication_IntegTest.java | 165 +++++++++
.../referenceapp-capability-inventory.json | 274 +++++++--------
.../inventory/ReferenceAppCapabilityInventory.java | 18 +-
.../referenceapp/referenceapp-targets.json | 2 +
.../ReferenceAppCapabilityInventoryTest.java | 2 +-
.../rich/mutation/RichMutationForProperty.java | 5 +
.../simple/mutation/SimpleMutationForProperty.java | 9 +-
.../marshallers/ScalarMarshallerBigDecimal.java | 6 +-
.../marshallers/ScalarMarshallerBigInteger.java | 6 +-
.../ScalarMarshallerJdk8OffsetDateTime.java | 9 +-
.../ScalarMarshallerJdk8OffsetTime.java | 9 +-
.../ScalarMarshallerJdk8ZonedDateTime.java | 12 +-
.../graphql/model/types/GraphQLValueScalars.java | 16 +
.../ScalarMarshallerBuiltInInputTest.java | 93 +++++
viewers/webcomponents/foundation/USAGE.adoc | 26 +-
.../foundation/src/editor-registry.mjs | 87 +++--
viewers/webcomponents/foundation/src/index.mjs | 1 +
.../src/interaction-controller-element.mjs | 87 +++--
.../foundation/src/property-element.mjs | 50 ++-
.../webcomponents/foundation/src/value-codecs.mjs | 382 +++++++++++++++++++++
.../foundation/test/interaction.test.mjs | 164 ++++++++-
.../foundation/test/value-codecs.test.mjs | 183 ++++++++++
.../petclinic/PetClinicHtmxPlaywrightTest.java | 7 +-
32 files changed, 1685 insertions(+), 269 deletions(-)
diff --git a/openspec/changes/archive/2026-08-22-add-vaadin-reference-widget-pilot/evidence/csp-harness/pilot-server.mjs b/openspec/changes/archive/2026-08-22-add-vaadin-reference-widget-pilot/evidence/csp-harness/pilot-server.mjs
index 539c4641cb8..9c4545c0218 100644
--- a/openspec/changes/archive/2026-08-22-add-vaadin-reference-widget-pilot/evidence/csp-harness/pilot-server.mjs
+++ b/openspec/changes/archive/2026-08-22-add-vaadin-reference-widget-pilot/evidence/csp-harness/pilot-server.mjs
@@ -8,11 +8,11 @@
import {createServer} from 'node:http';
import {existsSync, readFileSync} from 'node:fs';
-import {extname, resolve, sep} from 'node:path';
+import {dirname, extname, resolve, sep} from 'node:path';
import {fileURLToPath} from 'node:url';
const directory = fileURLToPath(new URL('.', import.meta.url));
-const project = resolve(directory, '../../../../..');
+const project = findProject(directory);
const foundation = resolve(project, 'viewers/webcomponents/foundation/src');
const production = resolve(project, 'viewers/webcomponents/foundation/vaadin-reference/generated/assets');
const matrix = JSON.parse(readFileSync(resolve(directory, '../results/csp-matrix.json'), 'utf8'));
@@ -41,6 +41,17 @@ export async function startPilotServer() {
return {server, origin: `http://127.0.0.1:${server.address().port}`};
}
+function findProject(start) {
+ let current = resolve(start);
+ while (dirname(current) !== current) {
+ if (existsSync(resolve(current, 'viewers/webcomponents/foundation/src'))) {
+ return current;
+ }
+ current = dirname(current);
+ }
+ throw new Error('Cannot locate the project root');
+}
+
function resolveFile(pathname) {
if (pathname === '/' || pathname === '/pilot.html') return resolve(directory, 'pilot.html');
if (pathname === '/pilot.css') return resolve(directory, 'pilot.css');
diff --git a/openspec/changes/harden-webcomponent-input-value-semantics/evidence/REPRODUCING.md b/openspec/changes/harden-webcomponent-input-value-semantics/evidence/REPRODUCING.md
new file mode 100644
index 00000000000..af7c0d6776a
--- /dev/null
+++ b/openspec/changes/harden-webcomponent-input-value-semantics/evidence/REPRODUCING.md
@@ -0,0 +1,73 @@
+# Reproducing input value semantics
+
+Run commands from the repository root with JDK 25.
+
+## Foundation codecs and interactions
+
+```bash
+cd viewers/webcomponents/foundation
+node --test
+```
+
+The suite covers exact numeric grammar and boundaries, nullable Boolean, local and offset temporal precision, daylight-saving overlap, protected-value redaction, URL policy, custom codec registration, property reconciliation, action defaults, cancellation, and stale work.
+
+## GraphQL marshallers
+
+```bash
+mvn -f viewers/graphql/pom.xml \
+ -pl model -am \
+ -Dtest=ScalarMarshallerBuiltInInputTest,ScalarMarshallerInputCapabilityTest,GraphQLValueScalarsTest \
+ -Dsurefire.failIfNoSpecifiedTests=false \
+ test
+```
+
+## GraphQL resource and protected-value policy
+
+```bash
+mvn -f viewers/graphql/pom.xml \
+ -pl test -am \
+ -Dtest=ResourceValuePolicy_IntegTest,ResourcePolicy_IntegTest \
+ -Dsurefire.failIfNoSpecifiedTests=false \
+ test
+```
+
+## Reference Application focused contract
+
+```bash
+mvn -f regressiontests/referenceapp/pom.xml \
+ -Dtest=ReferenceAppHtmxApplication_IntegTest#exactDecimalPropertyMutationUsesAdvertisedStringAndRestoresFixture+nullableBooleanAndOffsetAndZonedTemporalActionsRoundTrip \
+ -Dsurefire.failIfNoSpecifiedTests=false \
+ test
+```
+
+## Inventory review
+
+```bash
+mvn -f regressiontests/referenceapp/pom.xml \
+ -Dtest=ReferenceAppCapabilityInventoryTest,ReferenceAppCapabilityInventory_IntegTest \
+ -Dsurefire.failIfNoSpecifiedTests=false \
+ -Dreferenceapp.inventory.update=true \
+ test
+
+git diff -- regressiontests/referenceapp/htmx/src/test/resources/referenceapp-capability-inventory.json
+```
+
+The accepted inventory contains 4,286 items, including 3,421 supported items and no remaining value-semantics `VIEWER_DEFECT` classification.
+
+## Browser qualification
+
+```bash
+mvn -f regressiontests/referenceapp/pom.xml \
+ -Preferenceapp-playwright \
+ -Dtest=ReferenceAppHtmxPlaywrightTest \
+ -Dsurefire.failIfNoSpecifiedTests=false \
+ test
+```
+
+```bash
+mvn -f viewers/webcomponents/sample-htmx-petclinic/pom.xml \
+ -Pplaywright \
+ test
+```
+
+The full final gate also runs the existing Vaadin production CSP and rollback harness without changing its pinned bundle or CSP hashes.
diff --git a/openspec/changes/harden-webcomponent-input-value-semantics/evidence/final-gate-results.md b/openspec/changes/harden-webcomponent-input-value-semantics/evidence/final-gate-results.md
new file mode 100644
index 00000000000..787769fcf58
--- /dev/null
+++ b/openspec/changes/harden-webcomponent-input-value-semantics/evidence/final-gate-results.md
@@ -0,0 +1,22 @@
+# Final gate results
+
+## Passed
+
+- Foundation Node suite: 119 tests passed with zero failures.
+- GraphQL model reactor and all model tests: `BUILD SUCCESS`.
+- GraphQL resource, protected-value, unsupported-value, and structural-resource integration tests: `BUILD SUCCESS`.
+- Web-component foundation and HTMX Maven tests: `BUILD SUCCESS`.
+- Petclinic Playwright suite: 4 tests passed with zero failures, errors, or skips.
+- Reference Application ordinary clean package: `BUILD SUCCESS`.
+- Reference Application Playwright suite: 9 tests passed with zero failures, errors, or skips.
+- Reference Application capability inventory: 4,286 items, 3,421 `SUPPORTED`, 210 `GRACEFUL_UNSUPPORTED`, zero `GRAPHQL_GAP`, zero `VIEWER_DEFECT`, one `VIEWER_SPECIFIC`, and 654 reasoned `NOT_EXERCISED`.
+- Vaadin production-like pilot: 5 scenarios with zero axe, CSP, console, page, external-request, or overflow failures.
+- GraphQL model and Reference Application RAT checks: `BUILD SUCCESS`.
+- Strict OpenSpec validation and `git diff --check`: passed.
+
+## Security and compatibility observations
+
+Protected input did not appear in rendered value attributes, semantic prompt event values, bounded errors, or captured output.
+Malformed exact numeric, temporal, URL, resource, and unsupported custom input remained bounded and did not expose submitted values or implementation exceptions.
+No dependency file, operation name, route, public Causeway element, semantic event name, browser asset URL, Vaadin bundle, CSP hash, or default-selection policy changed.
+The direct standalone foundation RAT goal continues to report the pre-existing generated Vaadin bundle and its README as unknown-license files; neither is introduced or changed by this implementation, and all newly added source files carry the ASF header.
diff --git a/openspec/changes/harden-webcomponent-input-value-semantics/evidence/input-capability-inventory.md b/openspec/changes/harden-webcomponent-input-value-semantics/evidence/input-capability-inventory.md
new file mode 100644
index 00000000000..9c7574288ed
--- /dev/null
+++ b/openspec/changes/harden-webcomponent-input-value-semantics/evidence/input-capability-inventory.md
@@ -0,0 +1,47 @@
+# Effective input capability inventory
+
+## Discovery boundary
+
+The foundation currently receives the GraphQL argument `inputType` plus rich interaction state.
+Rich property and action-parameter state already selects a public `datatype` string, but the editor context previously discarded it.
+That datatype is the generated rich type name for the declared domain element type and is required to distinguish domain types that intentionally share one GraphQL scalar.
+This change carries the existing public datatype value into the toolkit-neutral codec context as `semanticType`.
+No new GraphQL operation or field is introduced.
+
+## Built-in scalar and semantic mappings
+
+| Domain type | Advertised GraphQL scalar | Semantic datatype needed by editor | Nullability source | Current input notes |
+| --- | --- | --- | --- | --- |
+| `BigDecimal` | `String` | generated datatype ending `BigDecimal` | GraphQL `NON_NULL` wrapper | String avoids precision loss; runtime marshaller must reconstruct `BigDecimal`. |
+| `BigInteger` | `String` | generated datatype ending `BigInteger` | GraphQL `NON_NULL` wrapper | String avoids precision loss; runtime marshaller must reconstruct `BigInteger`. |
+| primitive and wrapper `long` | extended `Long` scalar | generated datatype ending `Long` | primitive or GraphQL `NON_NULL` versus nullable wrapper | Values can exceed JavaScript safe integer range and remain lexical in the browser. |
+| primitive and wrapper Boolean | `Boolean` | generated datatype ending `Boolean` | primitive or GraphQL `NON_NULL` versus nullable wrapper | Nullable wrapper requires three distinct states. |
+| `LocalDate` | `String` | generated datatype ending `LocalDate` | GraphQL wrapper | ISO local date marshaller. |
+| `LocalTime` | `String` | generated datatype ending `LocalTime` | GraphQL wrapper | Local time and fractional precision must remain lexical. |
+| `LocalDateTime` | custom local-date-time scalar | generated datatype ending `LocalDateTime` | GraphQL wrapper | Native datetime-local is usable only without timezone conversion. |
+| `OffsetDateTime` | extended `DateTime` scalar | generated datatype ending `OffsetDateTime` | GraphQL wrapper | Explicit offset is semantically significant. |
+| `OffsetTime` | extended `Time` scalar | generated datatype ending `OffsetTime` | GraphQL wrapper | Explicit offset is semantically significant. |
+| `ZonedDateTime` | `String` | generated datatype ending `ZonedDateTime` | GraphQL wrapper | Server format is configurable and must remain explicit. |
+| `URL` | custom URL scalar | generated datatype ending `URL` | GraphQL wrapper | Existing safe resource-link output policy remains authoritative. |
+| `Password` | custom password scalar | generated datatype ending `Password` | GraphQL wrapper and member capability | Input is protected and output remains undisclosed. |
+| `Markup` | custom markup scalar | generated datatype ending `Markup` | GraphQL wrapper | Presentation value remains output-only. |
+
+## Resource inputs
+
+Blob and Clob output use dedicated rich resource projections and same-origin resource links.
+Local resource paths include representation and open-strategy semantics.
+The current generic editor context does not carry a complete authorized media-and-size input descriptor for these resource families.
+They therefore remain fail-closed for generic input until the required public metadata is present and tested.
+
+## Custom values
+
+Application marshallers can advertise output behavior without reversible input.
+A custom foundation codec must register explicitly against the existing semantic datatype and provide parsing and GraphQL-variable conversion.
+Unknown custom scalars and generated semantic datatypes select the unsupported codec and never fall back to an arbitrary string submission.
+
+## Consequences
+
+GraphQL `inputType` alone is insufficient for exact numeric, local temporal, zoned temporal, protected, resource, and custom values because several domain types share `String`.
+Codec selection therefore uses `semanticType` when rich datatype state is available and falls back to the GraphQL named type for standard scalars and enums.
+Property and action contexts retain required state from the GraphQL `NON_NULL` wrapper.
+Choices and references continue to preserve their existing identity representation before codec submission.
diff --git a/openspec/changes/harden-webcomponent-input-value-semantics/evidence/production-isolation.md b/openspec/changes/harden-webcomponent-input-value-semantics/evidence/production-isolation.md
new file mode 100644
index 00000000000..7d97bf050e6
--- /dev/null
+++ b/openspec/changes/harden-webcomponent-input-value-semantics/evidence/production-isolation.md
@@ -0,0 +1,18 @@
+# Production isolation verification
+
+No Maven POM, npm package manifest, lock file, or dependency version changes in this change.
+The GraphQL changes retain every existing query, mutation, field, argument, and route name.
+The `DateTime` and `Time` scalar names remain unchanged while their coercion now preserves accepted fractional precision.
+ZonedDateTime remains represented by the existing GraphQL string scalar and gains ISO named-zone input before the configured compatibility fallback.
+
+The public `<causeway-*>` element vocabulary and semantic event names remain unchanged.
+Protected property and action event payloads retain their existing shape but publish `null` instead of a secret value.
+No HTMX controller, page renderer, canonical route grammar, application default, or browser asset URL changes.
+
+No Vaadin package, generated bundle, checksum, CSP hash, qualification policy, or default-selection property changes.
+The optional reference adapter still loads lazily only for eligible reference inputs.
+Native and Vaadin reference editors now share the toolkit-neutral value codec boundary.
+
+The production foundation, HTMX, GraphQL model, Petclinic, and Reference Application modules package and test from their existing reactor positions.
+The archived Vaadin pilot server received only a path-discovery repair so its qualification harness continues to locate the project after archival.
+That harness completed five production-like pilot scenarios with zero axe, CSP, console, page, external-request, or overflow failures.
diff --git a/openspec/changes/harden-webcomponent-input-value-semantics/evidence/value-semantics-contract.md b/openspec/changes/harden-webcomponent-input-value-semantics/evidence/value-semantics-contract.md
new file mode 100644
index 00000000000..b503b95185e
--- /dev/null
+++ b/openspec/changes/harden-webcomponent-input-value-semantics/evidence/value-semantics-contract.md
@@ -0,0 +1,58 @@
+# Accepted input value semantics
+
+## Exact numerics
+
+`Long`, `BigInteger`, and `BigDecimal` are edited and submitted as lexical strings.
+The browser never converts those values through JavaScript `Number`.
+Long accepts signed whole numbers in the range `-9223372036854775808` through `9223372036854775807`.
+BigInteger accepts signed whole numbers without a browser-imposed precision limit.
+BigDecimal accepts signed decimal and exponent notation and preserves pending scale and trailing zeros.
+Malformed, empty-required, overflowed, and partially parsed values remain local bounded errors and do not invoke GraphQL.
+An authoritative server response may apply its documented output normalization after a successful mutation.
+
+## Machine numerics
+
+Byte, Short, and Int use JavaScript numbers only after strict integral grammar and declared range checks.
+Float and Double accept only finite JavaScript numbers.
+Empty optional input becomes `null`, while empty required input is rejected.
+
+## Boolean
+
+A required Boolean uses a two-state checkbox and submits only `true` or `false`.
+A nullable Boolean uses explicit no-value, true, and false choices and submits `null`, `true`, or `false` exactly.
+No untouched or unchecked state is inferred implicitly.
+
+## Temporal
+
+LocalDate, LocalTime, and LocalDateTime preserve local semantics without adding a browser timezone.
+LocalTime and LocalDateTime preserve accepted fractional seconds.
+OffsetTime and DateTime preserve explicit offsets and accepted fractional seconds.
+ZonedDateTime accepts ISO zoned input with a named zone and preserves the corresponding instant, offset, and zone.
+The server retains the configured zoned-date-time format as a compatibility input fallback.
+LegacyDateTime is treated as an explicit offset or instant representation rather than a browser-local date.
+Malformed temporal values fail before invocation and retain the pending lexical value for correction.
+
+## URL, protected, resource, and custom values
+
+URL input accepts absolute HTTP and HTTPS URLs in the generic browser codec.
+Existing GraphQL URL coercion and safe resource-link output policy remain authoritative.
+Password and protected inputs are write-only.
+Prior and submitted protected values are absent from generated value attributes, semantic state event values, and displayed error text.
+Blob, Clob, and local-resource inputs remain generically unsupported when complete public authorization, media, size, and representation metadata is unavailable.
+GraphQL continues to enforce accepted Blob and Clob inline limits, base64 validity, file-accept rules, non-disclosure, and metadata-only output.
+A custom value requires an explicitly registered reversible codec in addition to any output renderer.
+Unknown and output-only values fail closed and never receive generic string input.
+
+## Compatibility tightening
+
+Exact numeric coercion through JavaScript Number is intentionally removed.
+Nullable Boolean no longer collapses `null` to `false`.
+Offset and zoned values no longer use controls that discard offset or zone information.
+Malformed built-in scalar errors are bounded and do not include submitted values, Java class names, or parser exception details.
+Applications relying on lossy implicit conversion must register a deliberate reversible codec or correct their advertised GraphQL strategy.
+
+## Rollback
+
+The public Causeway elements, semantic events, GraphQL operation names, object identities, and routes are unchanged.
+The codec registry is the single internal rollback boundary for editor conversion.
+Rollback must not restore silent lossy conversion as an unsupported-value fallback.
diff --git a/openspec/changes/harden-webcomponent-input-value-semantics/tasks.md b/openspec/changes/harden-webcomponent-input-value-semantics/tasks.md
index 83910915818..ece4bf2ff00 100644
--- a/openspec/changes/harden-webcomponent-input-value-semantics/tasks.md
+++ b/openspec/changes/harden-webcomponent-input-value-semantics/tasks.md
@@ -1,60 +1,60 @@
## 1. Establish failing value-semantics baselines
-- [ ] 1.1 Add foundation codec tests reproducing `Long`, `BigInteger`, and `BigDecimal` precision loss, scale loss, malformed lexical input, and null handling.
-- [ ] 1.2 Add foundation tests reproducing nullable Boolean collapse and required Boolean behavior.
-- [ ] 1.3 Add foundation tests for local, offset, zoned, legacy, fractional-second, and malformed temporal inputs.
-- [ ] 1.4 Inventory effective GraphQL scalar names, input shapes, nullability, resource metadata, protected-value metadata, and custom-value strategies used by the editor contexts.
-- [ ] 1.5 Add GraphQL integration tests reproducing advertised exact-numeric strings reaching property or action invocation as incompatible Java values.
+- [x] 1.1 Add foundation codec tests reproducing `Long`, `BigInteger`, and `BigDecimal` precision loss, scale loss, malformed lexical input, and null handling.
+- [x] 1.2 Add foundation tests reproducing nullable Boolean collapse and required Boolean behavior.
+- [x] 1.3 Add foundation tests for local, offset, zoned, legacy, fractional-second, and malformed temporal inputs.
+- [x] 1.4 Inventory effective GraphQL scalar names, input shapes, nullability, resource metadata, protected-value metadata, and custom-value strategies used by the editor contexts.
+- [x] 1.5 Add GraphQL integration tests reproducing advertised exact-numeric strings reaching property or action invocation as incompatible Java values.
## 2. Introduce toolkit-neutral value codecs
-- [ ] 2.1 Add a value-codec registry with deterministic priority, named-type discovery, authoritative normalization, control conversion, parsing, and GraphQL-variable conversion contracts.
-- [ ] 2.2 Add a fail-closed unsupported codec that never converts display output into input implicitly.
-- [ ] 2.3 Refactor editor selection so native and optional toolkit editors consume codec contexts without changing public Causeway elements or semantic events.
-- [ ] 2.4 Route property validation and mutation values through the selected codec while retaining pending lexical state after rejection.
-- [ ] 2.5 Route action defaults, dependent preparation, validation, and invocation values through the selected codec with stale-response and cancellation protection.
-- [ ] 2.6 Export documented codec registration hooks for application-specific reversible values without exposing internal widget APIs.
+- [x] 2.1 Add a value-codec registry with deterministic priority, named-type discovery, authoritative normalization, control conversion, parsing, and GraphQL-variable conversion contracts.
+- [x] 2.2 Add a fail-closed unsupported codec that never converts display output into input implicitly.
+- [x] 2.3 Refactor editor selection so native and optional toolkit editors consume codec contexts without changing public Causeway elements or semantic events.
+- [x] 2.4 Route property validation and mutation values through the selected codec while retaining pending lexical state after rejection.
+- [x] 2.5 Route action defaults, dependent preparation, validation, and invocation values through the selected codec with stale-response and cancellation protection.
+- [x] 2.6 Export documented codec registration hooks for application-specific reversible values without exposing internal widget APIs.
## 3. Correct numeric and Boolean semantics
-- [ ] 3.1 Implement exact lexical codecs for `Long`, `BigInteger`, and `BigDecimal` without JavaScript `Number` conversion.
-- [ ] 3.2 Validate exact numeric sign, digits, decimal, exponent, range, required, and null rules with bounded field errors.
-- [ ] 3.3 Preserve decimal scale and trailing zeros through pending edit, validation, GraphQL variables, failure, cancellation, and authoritative reconciliation.
-- [ ] 3.4 Restrict JavaScript-number codecs to explicitly representable scalar and range contracts and test their boundaries.
-- [ ] 3.5 Implement distinct required two-state and nullable three-state Boolean editor behavior.
-- [ ] 3.6 Add property and action tests proving exact numeric and nullable Boolean defaults, choices, validation, invocation, cancellation, and stale-result handling.
+- [x] 3.1 Implement exact lexical codecs for `Long`, `BigInteger`, and `BigDecimal` without JavaScript `Number` conversion.
+- [x] 3.2 Validate exact numeric sign, digits, decimal, exponent, range, required, and null rules with bounded field errors.
+- [x] 3.3 Preserve decimal scale and trailing zeros through pending edit, validation, GraphQL variables, failure, cancellation, and authoritative reconciliation.
+- [x] 3.4 Restrict JavaScript-number codecs to explicitly representable scalar and range contracts and test their boundaries.
+- [x] 3.5 Implement distinct required two-state and nullable three-state Boolean editor behavior.
+- [x] 3.6 Add property and action tests proving exact numeric and nullable Boolean defaults, choices, validation, invocation, cancellation, and stale-result handling.
## 4. Complete temporal semantics
-- [ ] 4.1 Implement local date, local time, and local date-time codecs that preserve local meaning and accepted fractional precision.
-- [ ] 4.2 Implement offset date-time and offset time codecs that validate and preserve explicit offsets without browser-timezone conversion.
-- [ ] 4.3 Implement zoned date-time codec behavior using the effective advertised or configured format while preserving required zone information.
-- [ ] 4.4 Classify legacy temporal inputs explicitly and provide reversible codecs only where the public GraphQL contract is complete.
-- [ ] 4.5 Add property and action tests for timezone boundaries, daylight-saving transitions, fractional seconds, malformed values, nulls, cancellation, and authoritative reconciliation.
+- [x] 4.1 Implement local date, local time, and local date-time codecs that preserve local meaning and accepted fractional precision.
+- [x] 4.2 Implement offset date-time and offset time codecs that validate and preserve explicit offsets without browser-timezone conversion.
+- [x] 4.3 Implement zoned date-time codec behavior using the effective advertised or configured format while preserving required zone information.
+- [x] 4.4 Classify legacy temporal inputs explicitly and provide reversible codecs only where the public GraphQL contract is complete.
+- [x] 4.5 Add property and action tests for timezone boundaries, daylight-saving transitions, fractional seconds, malformed values, nulls, cancellation, and authoritative reconciliation.
## 5. Enforce protected, resource, and custom capabilities
-- [ ] 5.1 Implement safe URL input validation and preserve the existing resource-link output policy.
-- [ ] 5.2 Implement write-only password or protected-value handling that never exposes prior or submitted secrets in markup, events, errors, logs, or reconciliation.
-- [ ] 5.3 Inventory Blob, Clob, and local-resource input constraints and enable editors only when authorization, media, size, and representation metadata are complete.
-- [ ] 5.4 Add bounded Blob, Clob, and local-resource tests for accepted input, media mismatch, size rejection, cancellation, and unsupported metadata.
-- [ ] 5.5 Require custom value codecs to opt into reversible input explicitly and keep output-only custom values visibly unsupported for editing.
-- [ ] 5.6 Add extension tests proving registered custom codecs round-trip declared domain values and unregistered strategies fail closed.
+- [x] 5.1 Implement safe URL input validation and preserve the existing resource-link output policy.
+- [x] 5.2 Implement write-only password or protected-value handling that never exposes prior or submitted secrets in markup, events, errors, logs, or reconciliation.
+- [x] 5.3 Inventory Blob, Clob, and local-resource input constraints and enable editors only when authorization, media, size, and representation metadata are complete.
+- [x] 5.4 Add bounded Blob, Clob, and local-resource tests for accepted input, media mismatch, size rejection, cancellation, and unsupported metadata.
+- [x] 5.5 Require custom value codecs to opt into reversible input explicitly and keep output-only custom values visibly unsupported for editing.
+- [x] 5.6 Add extension tests proving registered custom codecs round-trip declared domain values and unregistered strategies fail closed.
## 6. Align rich GraphQL schema and runtime conversion
-- [ ] 6.1 Add scalar marshaller unit tests for exact numeric, nullable Boolean, URL, local, offset, zoned, protected, resource, and custom input representations.
-- [ ] 6.2 Fix shared property mutation unmarshalling so the scalar accepted by introspection is converted to the declared domain Java type before validation and invocation.
-- [ ] 6.3 Fix shared action argument unmarshalling through the same type-mapper path and reject incompatible values before domain invocation.
-- [ ] 6.4 Preserve exact numeric scale, temporal offset or zone, nullable state, and bounded validation errors through rich GraphQL end-to-end tests.
-- [ ] 6.5 Verify simple-schema behavior and existing compatibility configuration remain explicit and do not regain implicit lossy input.
-- [ ] 6.6 Add regression tests proving malformed or unsupported input does not invoke domain code or expose implementation exceptions.
+- [x] 6.1 Add scalar marshaller unit tests for exact numeric, nullable Boolean, URL, local, offset, zoned, protected, resource, and custom input representations.
+- [x] 6.2 Fix shared property mutation unmarshalling so the scalar accepted by introspection is converted to the declared domain Java type before validation and invocation.
+- [x] 6.3 Fix shared action argument unmarshalling through the same type-mapper path and reject incompatible values before domain invocation.
+- [x] 6.4 Preserve exact numeric scale, temporal offset or zone, nullable state, and bounded validation errors through rich GraphQL end-to-end tests.
+- [x] 6.5 Verify simple-schema behavior and existing compatibility configuration remain explicit and do not regain implicit lossy input.
+- [x] 6.6 Add regression tests proving malformed or unsupported input does not invoke domain code or expose implementation exceptions.
## 7. Qualify the corrected baseline
-- [ ] 7.1 Expand the Reference Application target catalogue and Playwright journeys for exact numeric, nullable Boolean, temporal, URL, protected, resource, and custom-value cases.
-- [ ] 7.2 Regenerate and review capability inventory classifications only after focused successful and rejected journeys pass.
-- [ ] 7.3 Run foundation Node tests, GraphQL model and integration tests, HTMX Maven tests, Petclinic browser tests, and Reference Application browser tests.
-- [ ] 7.4 Run strict CSP, accessibility, keyboard, light, dark, narrow, reduced-motion, forced-colors, external-request, console-error, page-error, and overflow gates.
-- [ ] 7.5 Document accepted lexical contracts, intentional compatibility tightening, extension registration, reproduction commands, and rollback.
-- [ ] 7.6 Verify production dependencies, public GraphQL operation names, Causeway elements, semantic events, routes, assets, and Vaadin default-selection policy remain unchanged.
+- [x] 7.1 Expand the Reference Application target catalogue and Playwright journeys for exact numeric, nullable Boolean, temporal, URL, protected, resource, and custom-value cases.
+- [x] 7.2 Regenerate and review capability inventory classifications only after focused successful and rejected journeys pass.
+- [x] 7.3 Run foundation Node tests, GraphQL model and integration tests, HTMX Maven tests, Petclinic browser tests, and Reference Application browser tests.
+- [x] 7.4 Run strict CSP, accessibility, keyboard, light, dark, narrow, reduced-motion, forced-colors, external-request, console-error, page-error, and overflow gates.
+- [x] 7.5 Document accepted lexical contracts, intentional compatibility tightening, extension registration, reproduction commands, and rollback.
+- [x] 7.6 Verify production dependencies, public GraphQL operation names, Causeway elements, semantic events, routes, assets, and Vaadin default-selection policy remain unchanged.
diff --git a/regressiontests/referenceapp/htmx/src/playwright/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxPlaywrightTest.java b/regressiontests/referenceapp/htmx/src/playwright/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxPlaywrightTest.java
index 598b4863733..65e6f38c47f 100644
--- a/regressiontests/referenceapp/htmx/src/playwright/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxPlaywrightTest.java
+++ b/regressiontests/referenceapp/htmx/src/playwright/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxPlaywrightTest.java
@@ -162,6 +162,8 @@ void valuesCollectionsHistoryAndWicketComparisonShareTheFixture() {
editableDecimal.locator("[data-causeway-action='edit']").click();
final Locator decimalEditor = editableDecimal.locator("input[data-causeway-editor='readWriteProperty']");
decimalEditor.waitFor();
+ assertThat(decimalEditor.getAttribute("type")).isEqualTo("text");
+ assertThat(decimalEditor.getAttribute("inputmode")).isEqualTo("decimal");
final String originalDecimal = decimalEditor.inputValue();
editableDecimal.locator("[data-causeway-action='cancel']").click();
@@ -221,6 +223,8 @@ void representativeBooleanEnumTemporalResourceAndCustomValueFamiliesAreRoutable(
new ValueFamily("demo_JavaTimeTypesMenu", "localDates", "rich__demo_LocalDates", "demo_LocalDates", "rich__demo_LocalDateEntity", "demo.LocalDateEntity"),
new ValueFamily("demo_JavaTimeTypesMenu", "offsetDateTimes", "rich__demo_OffsetDateTimes", "demo_OffsetDateTimes", "rich__demo_OffsetDateTimeEntity", "demo.OffsetDateTimeEntity"),
new ValueFamily("demo_JavaTimeTypesMenu", "zonedDateTimes", "rich__demo_ZonedDateTimes", "demo_ZonedDateTimes", "rich__demo_ZonedDateTimeEntity", "demo.ZonedDateTimeEntity"),
+ new ValueFamily("demo_JavaNetTypesMenu", "urls", "rich__demo_Urls", "demo_Urls", "rich__demo_UrlEntity", "demo.UrlEntity"),
+ new ValueFamily("demo_CausewayTypesMenu", "passwords", "rich__demo_CausewayPasswords", "demo_CausewayPasswords", "rich__demo_CausewayPasswordEntity", "demo.CausewayPasswordEntity"),
new ValueFamily("demo_CausewayTypesMenu", "blobs", "rich__demo_CausewayBlobs", "demo_CausewayBlobs", "rich__demo_CausewayBlobEntity", "demo.CausewayBlobEntity"),
new ValueFamily("demo_CausewayTypesMenu", "clobs", "rich__demo_CausewayClobs", "demo_CausewayClobs", "rich__demo_CausewayClobEntity", "demo.CausewayClobEntity"));
@@ -232,6 +236,26 @@ void representativeBooleanEnumTemporalResourceAndCustomValueFamiliesAreRoutable(
assertThat(page.locator(ROUTE_PAGE).getAttribute("data-route-state")).isIn("ready", "partial-error");
}
+ final String urlPageId = invokeViewModel("demo_JavaNetTypesMenu", "urls", "rich__demo_Urls");
+ openObject("demo.UrlEntity", firstCollectionEntityId(
+ "demo_Urls", urlPageId, "rich__demo_UrlEntity"));
+ final Locator urlProperty = page.locator("causeway-property[member='readWriteProperty']");
+ urlProperty.locator("[data-causeway-action='edit']").click();
+ assertThat(urlProperty.locator("input[data-causeway-editor='readWriteProperty']").getAttribute("type"))
+ .isEqualTo("url");
+ urlProperty.locator("[data-causeway-action='cancel']").click();
+
+ final String passwordPageId = invokeViewModel(
+ "demo_CausewayTypesMenu", "passwords", "rich__demo_CausewayPasswords");
+ openObject("demo.CausewayPasswordEntity", firstCollectionEntityId(
+ "demo_CausewayPasswords", passwordPageId, "rich__demo_CausewayPasswordEntity"));
+ final Locator passwordProperty = page.locator("causeway-property[member='readWriteProperty']");
+ passwordProperty.locator("[data-causeway-action='edit']").click();
+ final Locator passwordEditor = passwordProperty.locator("input[data-causeway-editor='readWriteProperty']");
+ assertThat(passwordEditor.getAttribute("type")).isEqualTo("password");
+ assertThat(passwordEditor.inputValue()).isEmpty();
+ passwordProperty.locator("[data-causeway-action='cancel']").click();
+
final String compositeId = invokeViewModel(
"demo_CompositeValueTypeMenu", "compositeValueTypes", "rich__demo_CompositeValuesPage");
page.navigate(url("/htmx/object/demo.CompositeValuesPage/"
diff --git a/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppCapabilityInventory_IntegTest.java b/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppCapabilityInventory_IntegTest.java
index cdbfe61e322..280b7e8870f 100644
--- a/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppCapabilityInventory_IntegTest.java
+++ b/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppCapabilityInventory_IntegTest.java
@@ -63,7 +63,7 @@ void capabilityInventoryMatchesReviewedBaseline() throws Exception {
assertJourneyTargetsExist(introspection);
assertThat(introspection.at("/data/__schema/types").size()).isGreaterThan(9_000);
assertThat(actual.path("itemCount").asInt()).isGreaterThan(4_000);
- assertThat(actual.at("/classificationCounts/VIEWER_DEFECT").asInt()).isGreaterThan(0);
+ assertThat(actual.at("/classificationCounts/VIEWER_DEFECT").asInt()).isZero();
assertThat(actual.at("/classificationCounts/GRACEFUL_UNSUPPORTED").asInt()).isGreaterThan(0);
assertThat(actual.at("/classificationCounts/VIEWER_SPECIFIC").asInt()).isGreaterThan(0);
assertThat(actual.at("/classificationCounts/NOT_EXERCISED").asInt()).isGreaterThan(0);
@@ -116,7 +116,7 @@ private void assertJourneyTargetsExist(final JsonNode introspection) throws Exce
"collection-polymorphism", "collection-associated-actions", "collection-stale-partial",
"boolean-nullable", "numeric-decimal", "numeric-integer", "temporal-local-date",
"temporal-offset-date-time", "temporal-zoned-date-time", "enum",
- "reference-autocomplete", "blob", "clob", "custom-value");
+ "reference-autocomplete", "url", "protected-password", "blob", "clob", "custom-value");
}
private JsonNode graphQL(final String query) throws Exception {
diff --git a/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxApplication_IntegTest.java b/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxApplication_IntegTest.java
index fb60c32bf47..5683089c06b 100644
--- a/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxApplication_IntegTest.java
+++ b/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxApplication_IntegTest.java
@@ -15,6 +15,7 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
+import java.time.ZonedDateTime;
import java.util.Base64;
import java.util.Map;
import java.util.regex.Pattern;
@@ -161,6 +162,170 @@ void exposesRepresentativeFixtureIdentityAndEffectiveGrid() throws Exception {
.doesNotContain("<bs:metadataError>");
}
+ @Test
+ void exactDecimalPropertyMutationUsesAdvertisedStringAndRestoresFixture() throws Exception {
+ final JsonNode open = graphQL("""
+ { rich { demo_JavaMathTypesMenu {
+ bigDecimals { invoke { results { _meta { id } } } }
+ } } }
+ """);
+ assertNoGraphQLErrors(open);
+ final String pageId = open.at("/data/rich/demo_JavaMathTypesMenu/bigDecimals/invoke/results/_meta/id").asText();
+ final JsonNode decimals = graphQL("""
+ { rich { demo_BigDecimals(object: {id: %s}) {
+ entities { get { ... on rich__demo_BigDecimalEntity {
+ _meta { id }
+ readWriteProperty { get }
+ } } }
+ } } }
+ """.formatted(OBJECT_MAPPER.writeValueAsString(pageId)));
+ assertNoGraphQLErrors(decimals);
+
+ final JsonNode action = graphQL("""
+ { rich { demo_BigDecimals(object: {id: %s}) {
+ openViewModel { invoke(initialValue: "9007199254740993.1200") {
+ results { ... on rich__demo_BigDecimalVm { readWriteProperty { get } } }
+ } }
+ } } }
+ """.formatted(OBJECT_MAPPER.writeValueAsString(pageId)));
+ assertNoGraphQLErrors(action);
+ assertThat(action.at("/data/rich/demo_BigDecimals/openViewModel/invoke/results/readWriteProperty/get").asText())
+ .isEqualTo("9007199254740993.1200");
+
+ final JsonNode entity = decimals.at("/data/rich/demo_BigDecimals/entities/get/0");
+ final String entityId = entity.at("/_meta/id").asText();
+ final String original = entity.at("/readWriteProperty/get").asText();
+ assertThat(entityId).isNotBlank();
+ assertThat(original).isNotBlank();
+
+ final String mutation = "mutation { demo_BigDecimalEntity__readWriteProperty(_target: {id: "
+ + OBJECT_MAPPER.writeValueAsString(entityId) + "}, readWriteProperty: %s) "
+ + "{ readWriteProperty { get } } }";
+ final String malformedValue = "PRIVATE_INVALID_DECIMAL_1.2.3";
+ final JsonNode malformed = graphQL(mutation.formatted(OBJECT_MAPPER.writeValueAsString(malformedValue)));
+ assertThat(malformed.at("/errors/0/message").asText())
+ .contains("Invalid BigDecimal value")
+ .doesNotContain(malformedValue, "NumberFormatException", "java.math");
+ final JsonNode unchanged = graphQL("{ rich { demo_BigDecimalEntity(object: {id: "
+ + OBJECT_MAPPER.writeValueAsString(entityId) + "}) { readWriteProperty { get } } } }");
+ assertNoGraphQLErrors(unchanged);
+ assertThat(unchanged.at("/data/rich/demo_BigDecimalEntity/readWriteProperty/get").asText())
+ .isEqualTo(original);
+ try {
+ final JsonNode changed = graphQL(mutation.formatted("\"9007199254740993.1200\""));
+ assertNoGraphQLErrors(changed);
+ assertThat(changed.at("/data/demo_BigDecimalEntity__readWriteProperty/readWriteProperty/get").asText())
+ .isEqualTo("9007199254740993.1200");
+ } finally {
+ final JsonNode restored = graphQL(mutation.formatted(OBJECT_MAPPER.writeValueAsString(original)));
+ assertNoGraphQLErrors(restored);
+ assertThat(restored.at("/data/demo_BigDecimalEntity__readWriteProperty/readWriteProperty/get").asText())
+ .isEqualTo(original);
+ }
+ }
+
+ @Test
+ void nullableBooleanAndOffsetAndZonedTemporalActionsRoundTrip() throws Exception {
+ final JsonNode booleans = graphQL("""
+ {
+ rich {
+ demo_JavaLangWrapperTypesMenu {
+ booleans {
+ invoke {
+ results {
+ ... on rich__demo_WrapperBooleans {
+ entities { get { ... on rich__demo_WrapperBooleanEntity {
+ _meta { id }
+ readWriteOptionalProperty { get }
+ } } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """);
+ assertNoGraphQLErrors(booleans);
+ final JsonNode booleanEntity = booleans.at(
+ "/data/rich/demo_JavaLangWrapperTypesMenu/booleans/invoke/results/entities/get/0");
+ final String booleanId = booleanEntity.at("/_meta/id").asText();
+ final JsonNode originalBoolean = booleanEntity.at("/readWriteOptionalProperty/get");
+ final String booleanMutation = "mutation { demo_WrapperBooleanEntity__readWriteOptionalProperty(_target: {id: "
+ + OBJECT_MAPPER.writeValueAsString(booleanId) + "}, readWriteOptionalProperty: %s) "
+ + "{ readWriteOptionalProperty { get } } }";
+ try {
+ final JsonNode falseValue = graphQL(booleanMutation.formatted("false"));
+ assertNoGraphQLErrors(falseValue);
+ assertThat(falseValue.at("/data/demo_WrapperBooleanEntity__readWriteOptionalProperty/readWriteOptionalProperty/get").asBoolean())
+ .isFalse();
+ final JsonNode nullValue = graphQL(booleanMutation.formatted("null"));
+ assertNoGraphQLErrors(nullValue);
+ assertThat(nullValue.at("/data/demo_WrapperBooleanEntity__readWriteOptionalProperty/readWriteOptionalProperty/get").isNull())
+ .isTrue();
+ } finally {
+ final JsonNode restored = graphQL(booleanMutation.formatted(
+ originalBoolean.isNull() ? "null" : Boolean.toString(originalBoolean.asBoolean())));
+ assertNoGraphQLErrors(restored);
+ }
+
+ final JsonNode offset = graphQL("""
+ {
+ rich {
+ demo_JavaTimeTypesMenu {
+ offsetDateTimes {
+ invoke {
+ results {
+ ... on rich__demo_OffsetDateTimes {
+ openViewModel {
+ invoke(initialValue: "2026-08-23T10:15:30.123456789-04:00") {
+ results {
+ ... on rich__demo_OffsetDateTimeVm { readWriteProperty { get } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """);
+ assertNoGraphQLErrors(offset);
+ assertThat(offset.at("/data/rich/demo_JavaTimeTypesMenu/offsetDateTimes/invoke/results/openViewModel/invoke/results/readWriteProperty/get").asText())
+ .contains("2026-08-23T10:15:30.123456789-04:00");
+
+ final String zonedValue = "2026-11-01T01:30:00-04:00[America/New_York]";
+ final JsonNode zoned = graphQL("""
+ {
+ rich {
+ demo_JavaTimeTypesMenu {
+ zonedDateTimes {
+ invoke {
+ results {
+ ... on rich__demo_ZonedDateTimes {
+ openViewModel {
+ invoke(initialValue: %s) {
+ results {
+ ... on rich__demo_ZonedDateTimeVm { readWriteProperty { get } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """.formatted(OBJECT_MAPPER.writeValueAsString(zonedValue)));
+ assertNoGraphQLErrors(zoned);
+ assertThat(ZonedDateTime.parse(zoned.at(
+ "/data/rich/demo_JavaTimeTypesMenu/zonedDateTimes/invoke/results/openViewModel/invoke/results/readWriteProperty/get").asText()))
+ .isEqualTo(ZonedDateTime.parse(zonedValue));
+ }
+
@Test
void semanticTargetCatalogueReferencesAdvertisedRichRoots() throws Exception {
final JsonNode rootType = graphQL("""
diff --git a/regressiontests/referenceapp/htmx/src/test/resources/referenceapp-capability-inventory.json b/regressiontests/referenceapp/htmx/src/test/resources/referenceapp-capability-inventory.json
index 6965a2f51e4..0aa41290564 100644
--- a/regressiontests/referenceapp/htmx/src/test/resources/referenceapp-capability-inventory.json
+++ b/regressiontests/referenceapp/htmx/src/test/resources/referenceapp-capability-inventory.json
@@ -4,14 +4,14 @@
"scope" : "rich demo base objects, semantic member wrappers, action parameters, enums, and advertised standard value shapes",
"itemCount" : 4286,
"classificationCounts" : {
- "SUPPORTED" : 3354,
+ "SUPPORTED" : 3421,
"GRACEFUL_UNSUPPORTED" : 210,
"GRAPHQL_GAP" : 0,
- "VIEWER_DEFECT" : 67,
+ "VIEWER_DEFECT" : 0,
"VIEWER_SPECIFIC" : 1,
"NOT_EXERCISED" : 654
},
- "itemsSha256" : "63be6e5616233982219fdd248b8e9fd7bc8936a293647da27e640eab2eca5cc2",
+ "itemsSha256" : "39f88fa795bbeea6e4f1eaf277ecd49ad38e9445e20a2bdef8e6d148c2ca927f",
"items" : [ {
"id" : "not-exercised:destructive-action-exhaustion",
"kind" : "EXECUTION_POLICY",
@@ -12823,50 +12823,50 @@
"id" : "rich__demo_JavaUtilDateEntity__readOnlyOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateEntity__readOnlyPropertyDerivedLabelPositionLeft__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateEntity__readOnlyPropertyDerivedLabelPositionNone__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateEntity__readOnlyPropertyDerivedLabelPositionRight__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateEntity__readOnlyPropertyDerivedLabelPositionTop__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateEntity__readOnlyProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateEntity__readWriteOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateEntity__readWriteProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateEntity__recentAuditTrailEntries__gqlv_action",
"kind" : "ACTION",
@@ -12906,8 +12906,8 @@
"id" : "rich__demo_JavaUtilDateHolder__mixinProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateHolder__updateReadOnlyOptionalPropertyWithChoices__gqlv_action",
"kind" : "ACTION",
@@ -12974,50 +12974,50 @@
"id" : "rich__demo_JavaUtilDateVm__readOnlyOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateVm__readOnlyPropertyDerivedLabelPositionLeft__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateVm__readOnlyPropertyDerivedLabelPositionNone__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateVm__readOnlyPropertyDerivedLabelPositionRight__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateVm__readOnlyPropertyDerivedLabelPositionTop__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateVm__readOnlyProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateVm__readWriteOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateVm__readWriteProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_JavaUtilDateVm__recentAuditTrailEntries__gqlv_action",
"kind" : "ACTION",
@@ -14550,50 +14550,50 @@
"id" : "rich__demo_OffsetDateTimeEntity__readOnlyOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeEntity__readOnlyPropertyDerivedLabelPositionLeft__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeEntity__readOnlyPropertyDerivedLabelPositionNone__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeEntity__readOnlyPropertyDerivedLabelPositionRight__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeEntity__readOnlyPropertyDerivedLabelPositionTop__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeEntity__readOnlyProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeEntity__readWriteOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeEntity__readWriteProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeEntity__recentAuditTrailEntries__gqlv_action",
"kind" : "ACTION",
@@ -14633,8 +14633,8 @@
"id" : "rich__demo_OffsetDateTimeHolder__mixinProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeHolder__updateReadOnlyOptionalPropertyWithChoices__gqlv_action",
"kind" : "ACTION",
@@ -14701,50 +14701,50 @@
"id" : "rich__demo_OffsetDateTimeVm__readOnlyOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeVm__readOnlyPropertyDerivedLabelPositionLeft__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeVm__readOnlyPropertyDerivedLabelPositionNone__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeVm__readOnlyPropertyDerivedLabelPositionRight__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeVm__readOnlyPropertyDerivedLabelPositionTop__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeVm__readOnlyProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeVm__readWriteOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeVm__readWriteProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The public scalar is advertised but the standard editor does not preserve its temporal semantics."
+ "classification" : "SUPPORTED",
+ "reason" : "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics."
}, {
"id" : "rich__demo_OffsetDateTimeVm__recentAuditTrailEntries__gqlv_action",
"kind" : "ACTION",
@@ -16726,38 +16726,38 @@
"id" : "rich__demo_PrimitiveLongEntity__readOnlyPropertyDerivedLabelPositionLeft__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongEntity__readOnlyPropertyDerivedLabelPositionNone__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongEntity__readOnlyPropertyDerivedLabelPositionRight__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongEntity__readOnlyPropertyDerivedLabelPositionTop__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongEntity__readOnlyProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongEntity__readWriteProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongEntity__recentAuditTrailEntries__gqlv_action",
"kind" : "ACTION",
@@ -16797,8 +16797,8 @@
"id" : "rich__demo_PrimitiveLongHolder__mixinProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongHolder__updateReadOnlyPropertyWithChoices__gqlv_action",
"kind" : "ACTION",
@@ -16845,38 +16845,38 @@
"id" : "rich__demo_PrimitiveLongVm__readOnlyPropertyDerivedLabelPositionLeft__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongVm__readOnlyPropertyDerivedLabelPositionNone__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongVm__readOnlyPropertyDerivedLabelPositionRight__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongVm__readOnlyPropertyDerivedLabelPositionTop__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongVm__readOnlyProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongVm__readWriteProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_PrimitiveLongVm__recentAuditTrailEntries__gqlv_action",
"kind" : "ACTION",
@@ -21981,50 +21981,50 @@
"id" : "rich__demo_WrapperLongEntity__readOnlyOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongEntity__readOnlyPropertyDerivedLabelPositionLeft__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongEntity__readOnlyPropertyDerivedLabelPositionNone__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongEntity__readOnlyPropertyDerivedLabelPositionRight__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongEntity__readOnlyPropertyDerivedLabelPositionTop__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongEntity__readOnlyProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongEntity__readWriteOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongEntity__readWriteProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongEntity__recentAuditTrailEntries__gqlv_action",
"kind" : "ACTION",
@@ -22064,8 +22064,8 @@
"id" : "rich__demo_WrapperLongHolder__mixinProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongHolder__updateReadOnlyOptionalPropertyWithChoices__gqlv_action",
"kind" : "ACTION",
@@ -22132,50 +22132,50 @@
"id" : "rich__demo_WrapperLongVm__readOnlyOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongVm__readOnlyPropertyDerivedLabelPositionLeft__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongVm__readOnlyPropertyDerivedLabelPositionNone__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongVm__readOnlyPropertyDerivedLabelPositionRight__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongVm__readOnlyPropertyDerivedLabelPositionTop__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongVm__readOnlyProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongVm__readWriteOptionalProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongVm__readWriteProperty__gqlv_property",
"kind" : "PROPERTY",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The current standard editor coerces this exact numeric type through JavaScript Number."
+ "classification" : "SUPPORTED",
+ "reason" : "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion."
}, {
"id" : "rich__demo_WrapperLongVm__recentAuditTrailEntries__gqlv_action",
"kind" : "ACTION",
@@ -22934,8 +22934,8 @@
"id" : "value-shape:DateTime",
"kind" : "VALUE_SHAPE",
"valueType" : "DateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The scalar is public but the current standard editor is not reversibly correct."
+ "classification" : "SUPPORTED",
+ "reason" : "The scalar has a tested reversible exact or temporal codec and GraphQL marshaller."
}, {
"id" : "value-shape:Float",
"kind" : "VALUE_SHAPE",
@@ -22958,8 +22958,8 @@
"id" : "value-shape:LegacyDateTime",
"kind" : "VALUE_SHAPE",
"valueType" : "LegacyDateTime",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The scalar is public but the current standard editor is not reversibly correct."
+ "classification" : "SUPPORTED",
+ "reason" : "The scalar has a tested reversible exact or temporal codec and GraphQL marshaller."
}, {
"id" : "value-shape:LocalDateTime",
"kind" : "VALUE_SHAPE",
@@ -22976,8 +22976,8 @@
"id" : "value-shape:Long",
"kind" : "VALUE_SHAPE",
"valueType" : "Long",
- "classification" : "VIEWER_DEFECT",
- "reason" : "The scalar is public but the current standard editor is not reversibly correct."
+ "classification" : "SUPPORTED",
+ "reason" : "The scalar has a tested reversible exact or temporal codec and GraphQL marshaller."
}, {
"id" : "value-shape:Short",
"kind" : "VALUE_SHAPE",
diff --git a/regressiontests/referenceapp/support-jpa/src/main/java/org/apache/causeway/regressiontests/referenceapp/inventory/ReferenceAppCapabilityInventory.java b/regressiontests/referenceapp/support-jpa/src/main/java/org/apache/causeway/regressiontests/referenceapp/inventory/ReferenceAppCapabilityInventory.java
index ea3ceb5c70d..89f98790fec 100644
--- a/regressiontests/referenceapp/support-jpa/src/main/java/org/apache/causeway/regressiontests/referenceapp/inventory/ReferenceAppCapabilityInventory.java
+++ b/regressiontests/referenceapp/support-jpa/src/main/java/org/apache/causeway/regressiontests/referenceapp/inventory/ReferenceAppCapabilityInventory.java
@@ -32,7 +32,7 @@ public final class ReferenceAppCapabilityInventory {
private static final Set<String> SUPPORTED_SCALARS = Set.of(
"Boolean", "Byte", "Char", "Float", "ID", "Int", "Locale", "Short", "String",
"LocalDate", "LocalDateTime", "LocalTime", "UUID", "Url");
- private static final Set<String> UNSUPPORTED_TEMPORAL_TYPES = Set.of(
+ private static final Set<String> TEMPORAL_TYPES = Set.of(
"DateTime", "LegacyDateTime", "OffsetDateTime", "OffsetTime", "ZonedDateTime");
private static final Set<String> RESOURCE_TYPES = Set.of(
"BlobValue", "ClobValue", "LocalResourcePathInput", "LocalResourcePathValue");
@@ -141,12 +141,12 @@ private Item classifyProperty(final String name, final String valueType) {
"GraphQL explicitly advertises an unsupported value and the viewer renders a bounded unsupported state.");
}
if (EXACT_NUMERIC_TYPES.contains(valueType)) {
- return new Item(name, "PROPERTY", valueType, Classification.VIEWER_DEFECT,
- "The current standard editor coerces this exact numeric type through JavaScript Number.");
+ return new Item(name, "PROPERTY", valueType, Classification.SUPPORTED,
+ "The toolkit-neutral exact codec preserves lexical precision without JavaScript Number coercion.");
}
- if (UNSUPPORTED_TEMPORAL_TYPES.contains(valueType)) {
- return new Item(name, "PROPERTY", valueType, Classification.VIEWER_DEFECT,
- "The public scalar is advertised but the standard editor does not preserve its temporal semantics.");
+ if (TEMPORAL_TYPES.contains(valueType)) {
+ return new Item(name, "PROPERTY", valueType, Classification.SUPPORTED,
+ "The type-specific temporal codec preserves the advertised local, offset, zone, and precision semantics.");
}
if (valueType.startsWith("LocalResourcePath")) {
return new Item(name, "PROPERTY", valueType, Classification.GRACEFUL_UNSUPPORTED,
@@ -216,9 +216,9 @@ private void addValueShapeItems(final List<Item> items, final JsonNode types) {
if (SUPPORTED_SCALARS.contains(name)) {
items.add(new Item("value-shape:" + name, "VALUE_SHAPE", name, Classification.SUPPORTED,
"The scalar has an established semantic value contract."));
- } else if (EXACT_NUMERIC_TYPES.contains(name) || UNSUPPORTED_TEMPORAL_TYPES.contains(name)) {
- items.add(new Item("value-shape:" + name, "VALUE_SHAPE", name, Classification.VIEWER_DEFECT,
- "The scalar is public but the current standard editor is not reversibly correct."));
+ } else if (EXACT_NUMERIC_TYPES.contains(name) || TEMPORAL_TYPES.contains(name)) {
+ items.add(new Item("value-shape:" + name, "VALUE_SHAPE", name, Classification.SUPPORTED,
+ "The scalar has a tested reversible exact or temporal codec and GraphQL marshaller."));
} else if (RESOURCE_TYPES.contains(name)) {
items.add(new Item("value-shape:" + name, "VALUE_SHAPE", name, Classification.GRACEFUL_UNSUPPORTED,
"Resource output is bounded while resource input remains explicitly unsupported."));
diff --git a/regressiontests/referenceapp/support-jpa/src/main/resources/org/apache/causeway/regressiontests/referenceapp/referenceapp-targets.json b/regressiontests/referenceapp/support-jpa/src/main/resources/org/apache/causeway/regressiontests/referenceapp/referenceapp-targets.json
index acd88211064..90cf2533701 100644
--- a/regressiontests/referenceapp/support-jpa/src/main/resources/org/apache/causeway/regressiontests/referenceapp/referenceapp-targets.json
+++ b/regressiontests/referenceapp/support-jpa/src/main/resources/org/apache/causeway/regressiontests/referenceapp/referenceapp-targets.json
@@ -31,6 +31,8 @@
{"family": "temporal-zoned-date-time", "rootField": "demo_ZonedDateTimes", "member": "entities"},
{"family": "enum", "rootField": "demo_JavaLangEnums", "member": "entities"},
{"family": "reference-autocomplete", "rootField": "demo_DomainObjectAutoCompleteEntity", "member": "name"},
+ {"family": "url", "rootField": "demo_Urls", "member": "entities"},
+ {"family": "protected-password", "rootField": "demo_CausewayPasswords", "member": "entities"},
{"family": "blob", "rootField": "demo_CausewayBlobs", "member": "entities"},
{"family": "clob", "rootField": "demo_CausewayClobs", "member": "entities"},
{"family": "custom-value", "rootField": "demo_CompositeValuesPage", "member": "complexNumber"}
diff --git a/regressiontests/referenceapp/support-jpa/src/test/java/org/apache/causeway/regressiontests/referenceapp/inventory/ReferenceAppCapabilityInventoryTest.java b/regressiontests/referenceapp/support-jpa/src/test/java/org/apache/causeway/regressiontests/referenceapp/inventory/ReferenceAppCapabilityInventoryTest.java
index 011f9f32130..b57bb35d4ab 100644
--- a/regressiontests/referenceapp/support-jpa/src/test/java/org/apache/causeway/regressiontests/referenceapp/inventory/ReferenceAppCapabilityInventoryTest.java
+++ b/regressiontests/referenceapp/support-jpa/src/test/java/org/apache/causeway/regressiontests/referenceapp/inventory/ReferenceAppCapabilityInventoryTest.java
@@ -43,7 +43,7 @@ void classifiesRepresentativeDomainAndValueShapesDeterministically() throws Exce
assertThat(first).isEqualTo(second);
assertThat(first.path("itemCount").asInt()).isEqualTo(12);
assertThat(classification(first, "rich__demo_Sample__name__gqlv_property")).isEqualTo("SUPPORTED");
- assertThat(classification(first, "rich__demo_Sample__amount__gqlv_property")).isEqualTo("VIEWER_DEFECT");
+ assertThat(classification(first, "rich__demo_Sample__amount__gqlv_property")).isEqualTo("SUPPORTED");
assertThat(classification(first, "rich__demo_Sample__custom__gqlv_property")).isEqualTo("GRACEFUL_UNSUPPORTED");
assertThat(classification(first, "rich__demo_Sample__change__value__gqlv_action_parameter")).isEqualTo("NOT_EXERCISED");
assertThat(first.toString()).doesNotContain("secret", "stack trace");
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/mutation/RichMutationForProperty.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/mutation/RichMutationForProperty.java
index 6e609c619ab..2c866544343 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/mutation/RichMutationForProperty.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/mutation/RichMutationForProperty.java
@@ -102,6 +102,11 @@ protected Object fetchData(final DataFetchingEnvironment dataFetchingEnvironment
environment,
context);
ResourceValueTypes.validateFileAccept(oneToOneAssociation, argumentPojo);
+ if (argumentPojo != null
+ && !oneToOneAssociation.getElementType().getCorrespondingClass().isInstance(argumentPojo)) {
+ throw new IllegalArgumentException(
+ "GraphQL input cannot be converted to the declared property type.");
+ }
var argumentManagedObject = ManagedObject.adaptProperty(oneToOneAssociation, argumentPojo);
var validityConsent = oneToOneAssociation.isAssociationValid(managedObject, argumentManagedObject, InteractionInitiatedBy.USER);
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/mutation/SimpleMutationForProperty.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/mutation/SimpleMutationForProperty.java
index cf547124bf9..8753819c677 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/mutation/SimpleMutationForProperty.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/simple/mutation/SimpleMutationForProperty.java
@@ -43,6 +43,7 @@
import org.apache.causeway.viewer.graphql.model.exceptions.DisabledException;
import org.apache.causeway.viewer.graphql.model.exceptions.HiddenException;
import org.apache.causeway.viewer.graphql.model.exceptions.InvalidException;
+import org.apache.causeway.viewer.graphql.model.types.ResourceValueTypes;
import org.apache.causeway.viewer.graphql.model.fetcher.BookmarkedPojo;
import org.apache.causeway.viewer.graphql.model.types.TypeMapper;
@@ -121,7 +122,13 @@ protected Object fetchData(final DataFetchingEnvironment dataFetchingEnvironment
Map<String, Object> arguments = dataFetchingEnvironment.getArguments();
Object argumentValue = arguments.get(oneToOneAssociation.asciiId());
- ManagedObject argumentManagedObject = ManagedObject.adaptProperty(oneToOneAssociation, argumentValue);
+ Object argumentPojo = ObjectFeatureUtils.unmarshalValue(
+ oneToOneAssociation.getElementType(),
+ argumentValue,
+ environment,
+ context);
+ ResourceValueTypes.validateFileAccept(oneToOneAssociation, argumentPojo);
+ ManagedObject argumentManagedObject = ManagedObject.adaptProperty(oneToOneAssociation, argumentPojo);
var visibleConsent = oneToOneAssociation.isVisible(managedObject, InteractionInitiatedBy.USER, Where.ANYWHERE);
if (visibleConsent.isVetoed()) {
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerBigDecimal.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerBigDecimal.java
index 3a84771f46e..02ce7394213 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerBigDecimal.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerBigDecimal.java
@@ -45,6 +45,10 @@ public ScalarMarshallerBigDecimal(final CausewayConfiguration causewayConfigurat
@Override
public BigDecimal unmarshal(Object graphValue, Class<?> targetType) {
- return new BigDecimal((String) graphValue);
+ try {
+ return new BigDecimal((String) graphValue);
+ } catch (RuntimeException ignored) {
+ throw new IllegalArgumentException("Invalid BigDecimal value");
+ }
}
}
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerBigInteger.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerBigInteger.java
index 287fa0280c3..e05d05dc068 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerBigInteger.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerBigInteger.java
@@ -45,6 +45,10 @@ public ScalarMarshallerBigInteger(final CausewayConfiguration causewayConfigurat
@Override
public BigInteger unmarshal(Object graphValue, Class<?> targetType) {
- return new BigInteger((String) graphValue);
+ try {
+ return new BigInteger((String) graphValue);
+ } catch (RuntimeException ignored) {
+ throw new IllegalArgumentException("Invalid BigInteger value");
+ }
}
}
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8OffsetDateTime.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8OffsetDateTime.java
index bdb2ad91e38..7a203c0716b 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8OffsetDateTime.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8OffsetDateTime.java
@@ -28,8 +28,7 @@
import org.apache.causeway.applib.annotation.PriorityPrecedence;
import org.apache.causeway.core.config.CausewayConfiguration;
import org.apache.causeway.viewer.graphql.applib.marshallers.ScalarMarshallerAbstract;
-
-import graphql.scalars.ExtendedScalars;
+import org.apache.causeway.viewer.graphql.model.types.GraphQLValueScalars;
@Component
@Priority(PriorityPrecedence.LATE)
@@ -37,11 +36,13 @@ public class ScalarMarshallerJdk8OffsetDateTime extends ScalarMarshallerAbstract
@Inject
public ScalarMarshallerJdk8OffsetDateTime(final CausewayConfiguration causewayConfiguration) {
- super(OffsetDateTime.class, ExtendedScalars.DateTime, causewayConfiguration, true);
+ super(OffsetDateTime.class, GraphQLValueScalars.OFFSET_DATE_TIME, causewayConfiguration, true);
}
@Override
public OffsetDateTime unmarshal(Object graphValue, Class<?> targetType) {
- return (OffsetDateTime) graphValue;
+ return graphValue instanceof OffsetDateTime offsetDateTime
+ ? offsetDateTime
+ : OffsetDateTime.parse((String) graphValue);
}
}
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8OffsetTime.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8OffsetTime.java
index ca5628d8c2d..d3f25004396 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8OffsetTime.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8OffsetTime.java
@@ -28,8 +28,7 @@
import org.apache.causeway.applib.annotation.PriorityPrecedence;
import org.apache.causeway.core.config.CausewayConfiguration;
import org.apache.causeway.viewer.graphql.applib.marshallers.ScalarMarshallerAbstract;
-
-import graphql.scalars.ExtendedScalars;
+import org.apache.causeway.viewer.graphql.model.types.GraphQLValueScalars;
@Component
@Priority(PriorityPrecedence.LATE)
@@ -37,11 +36,13 @@ public class ScalarMarshallerJdk8OffsetTime extends ScalarMarshallerAbstract<Off
@Inject
public ScalarMarshallerJdk8OffsetTime(final CausewayConfiguration causewayConfiguration) {
- super(OffsetTime.class, ExtendedScalars.Time, causewayConfiguration, true);
+ super(OffsetTime.class, GraphQLValueScalars.OFFSET_TIME, causewayConfiguration, true);
}
@Override
public OffsetTime unmarshal(Object graphValue, Class<?> targetType) {
- return (OffsetTime) graphValue;
+ return graphValue instanceof OffsetTime offsetTime
+ ? offsetTime
+ : OffsetTime.parse((String) graphValue);
}
}
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8ZonedDateTime.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8ZonedDateTime.java
index 8441d059fb9..0a40fbd3f3b 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8ZonedDateTime.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerJdk8ZonedDateTime.java
@@ -20,6 +20,7 @@
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
@@ -46,7 +47,14 @@ public ScalarMarshallerJdk8ZonedDateTime(final CausewayConfiguration causewayCon
@Override
public ZonedDateTime unmarshal(Object graphValue, Class<?> targetType) {
- String argumentStr = (String) graphValue;
- return ZonedDateTime.parse(argumentStr, DateTimeFormatter.ofPattern(scalarMarshallerConfig.zonedDateTimeFormat()));
+ return parse((String) graphValue, scalarMarshallerConfig.zonedDateTimeFormat());
+ }
+
+ static ZonedDateTime parse(final String value, final String configuredFormat) {
+ try {
+ return ZonedDateTime.parse(value, DateTimeFormatter.ISO_ZONED_DATE_TIME);
+ } catch (DateTimeParseException ignored) {
+ return ZonedDateTime.parse(value, DateTimeFormatter.ofPattern(configuredFormat));
+ }
}
}
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/types/GraphQLValueScalars.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/types/GraphQLValueScalars.java
index 093719a1cbf..27f56746786 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/types/GraphQLValueScalars.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/types/GraphQLValueScalars.java
@@ -25,6 +25,8 @@
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.Locale;
@@ -56,6 +58,20 @@ public class GraphQLValueScalars {
value -> LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME),
value -> DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(value));
+ public static final GraphQLScalarType OFFSET_DATE_TIME = stringScalar(
+ "DateTime",
+ "An ISO-8601 date-time with an explicit offset; fractional seconds are preserved.",
+ OffsetDateTime.class,
+ value -> OffsetDateTime.parse(value, DateTimeFormatter.ISO_OFFSET_DATE_TIME),
+ value -> DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value));
+
+ public static final GraphQLScalarType OFFSET_TIME = stringScalar(
+ "Time",
+ "An ISO-8601 time with an explicit offset; fractional seconds are preserved.",
+ OffsetTime.class,
+ value -> OffsetTime.parse(value, DateTimeFormatter.ISO_OFFSET_TIME),
+ value -> DateTimeFormatter.ISO_OFFSET_TIME.format(value));
+
public static final GraphQLScalarType URL_VALUE = stringScalar(
"Url",
"An absolute URL represented by its normalized external form.",
diff --git a/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerBuiltInInputTest.java b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerBuiltInInputTest.java
new file mode 100644
index 00000000000..40048627812
--- /dev/null
+++ b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/marshallers/ScalarMarshallerBuiltInInputTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.model.marshallers;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.net.URL;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.causeway.applib.value.Password;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ScalarMarshallerBuiltInInputTest {
+
+ @Test
+ void exactNumericStringsBecomeDeclaredJavaTypesWithoutPrecisionLoss() {
+ final var decimal = new ScalarMarshallerBigDecimal(null)
+ .unmarshal("9007199254740993.1200", BigDecimal.class);
+ final var integer = new ScalarMarshallerBigInteger(null)
+ .unmarshal("123456789012345678901234567890", BigInteger.class);
+
+ assertThat(decimal).isEqualByComparingTo(new BigDecimal("9007199254740993.1200"));
+ assertThat(decimal.scale()).isEqualTo(4);
+ assertThat(integer).isEqualTo(new BigInteger("123456789012345678901234567890"));
+ assertThatThrownBy(() -> new ScalarMarshallerBigDecimal(null).unmarshal("1.2.3", BigDecimal.class))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Invalid BigDecimal value");
+ }
+
+ @Test
+ void nullableBooleanAndProtectedValuesPreserveTheirDeclaredTypes() {
+ assertThat(new ScalarMarshallerBooleanWrapper(null).unmarshal(null, Boolean.class)).isNull();
+ assertThat(new ScalarMarshallerBooleanWrapper(null).unmarshal(Boolean.FALSE, Boolean.class)).isFalse();
+ assertThat(new ScalarMarshallerPassword(null).unmarshal("new secret", Password.class))
+ .isInstanceOf(Password.class);
+ }
+
+ @Test
+ void localOffsetAndZonedTemporalValuesPreservePrecisionOffsetAndZone() {
+ final var local = new ScalarMarshallerJdk8LocalDateTime(null)
+ .unmarshal("2026-08-23T10:15:30.123456789", LocalDateTime.class);
+ final var offsetDateTime = OffsetDateTime.parse("2026-08-23T10:15:30.123456789-04:00");
+ final var offsetTime = OffsetTime.parse("10:15:30.123456789+05:30");
+ final var zoned = ScalarMarshallerJdk8ZonedDateTime.parse(
+ "2026-11-01T01:30:00-04:00[America/New_York]",
+ "yyyy-MM-dd'T'HH:mm:ssXXX");
+
+ assertThat(local.getNano()).isEqualTo(123456789);
+ assertThat(new ScalarMarshallerJdk8OffsetDateTime(null)
+ .unmarshal(offsetDateTime, OffsetDateTime.class)).isEqualTo(offsetDateTime);
+ assertThat(new ScalarMarshallerJdk8OffsetTime(null)
+ .unmarshal(offsetTime, OffsetTime.class)).isEqualTo(offsetTime);
+ assertThat(zoned.getZone()).isEqualTo(ZoneId.of("America/New_York"));
+ assertThat(zoned.getOffset().getId()).isEqualTo("-04:00");
+ assertThat(ScalarMarshallerJdk8ZonedDateTime.parse(
+ "2026-08-23T10:15:30+02:00",
+ "yyyy-MM-dd'T'HH:mm:ssXXX").getOffset().getId()).isEqualTo("+02:00");
+ }
+
+ @Test
+ void urlInputIsTypedAndRejectsMalformedValues() throws Exception {
+ final URL value = new ScalarMarshallerUrl(null).unmarshal("https://causeway.apache.org/", URL.class);
+
+ assertThat(value).isEqualTo(new URL("https://causeway.apache.org/"));
+ assertThatThrownBy(() -> new ScalarMarshallerUrl(null).unmarshal("not a URL", URL.class))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Invalid URL value");
+ }
+}
diff --git a/viewers/webcomponents/foundation/USAGE.adoc b/viewers/webcomponents/foundation/USAGE.adoc
index 8ca6f30df75..073ca9b47e3 100644
--- a/viewers/webcomponents/foundation/USAGE.adoc
+++ b/viewers/webcomponents/foundation/USAGE.adoc
@@ -283,9 +283,16 @@ A successful save invalidates the complete active object projection so supportin
Hidden, disabled, query-only, and unsupported properties expose no enabled save path.
The default editor registry covers single-line and effective-grid-directed multiline text, numeric, temporal, boolean, enum, fixed-choice, and advertised autocomplete inputs.
+A toolkit-neutral value codec is selected before the internal editor so native and optional toolkit controls submit identical semantic values.
+`Long`, `BigInteger`, and `BigDecimal` remain validated lexical strings and never pass through JavaScript `Number`.
+Nullable Boolean uses explicit no-value, true, and false states, while a required Boolean remains two-state.
+Local temporal values preserve local meaning, offset values preserve their offsets, and zoned values preserve named-zone information when the advertised representation supplies it.
+Password and protected inputs are write-only and their prior or pending values are redacted from markup and semantic state events.
+Blob, Clob, local-resource, presentation-only, and unknown custom inputs fail closed unless complete public metadata and a reversible codec are registered.
A valid `multiLine` value greater than one selects a textarea and is capped at 50 rows; malformed and non-positive values retain the single-line editor.
Applications can register a more-specific editor through `defaultEditorRegistry.register(...)`, or assign another `CausewayEditorRegistry` to a property's `editorRegistry` JavaScript property.
-Editors render and parse pending semantic values but never execute GraphQL independently.
+Editors render pending semantic values but never execute GraphQL independently.
+Codecs own authoritative normalization, control conversion, parsing, local validation, and GraphQL-variable conversion.
=== Optional Vaadin reference widgets
@@ -382,8 +389,23 @@ const release = defaultEditorRegistry.register({
});
----
+Custom value input requires an explicit reversible codec registration:
+
+[source,javascript]
+----
+const releaseMoneyCodec = defaultValueCodecRegistry.register({
+ id: 'application-money',
+ priority: 1000,
+ supports: state => state.semanticType === 'rich__example_Money',
+ toControlValue: value => value?.amount ?? '',
+ parse: input => ({amount: input.value, currency: 'GBP'}),
+ toGraphQLValue: value => value
+});
+----
+
+A display renderer or editor alone never opts a custom value into GraphQL input.
Custom prompts must preserve labels, validation associations, pending announcements, cancellation, focus restoration, and semantic result events.
-Call `release()` when the editor override is no longer required.
+Call `release()` and `releaseMoneyCodec()` when the overrides are no longer required.
The object context exposes `prepareAction`, `autoCompleteActionParameter`, `validateAction`, and `invokeAction` for those integrations.
Safe actions use the advertised rich query invocation field.
diff --git a/viewers/webcomponents/foundation/src/editor-registry.mjs b/viewers/webcomponents/foundation/src/editor-registry.mjs
index 4a858217800..9f360e0e60d 100644
--- a/viewers/webcomponents/foundation/src/editor-registry.mjs
+++ b/viewers/webcomponents/foundation/src/editor-registry.mjs
@@ -17,9 +17,9 @@
* under the License.
*/
-import {namedType} from './introspection.mjs';
import {escapeHtml} from './rendering.mjs';
import {renderCausewayReferenceWidget, supportsCausewayReferenceWidget} from './reference-widget.mjs';
+import {defaultValueCodecRegistry, parseCausewayValue, semanticTypeName} from './value-codecs.mjs';
export class CausewayEditorRegistry {
constructor(registrations = []) {
@@ -98,44 +98,54 @@ const autoCompleteEditor = Object.freeze({
}
});
-const booleanEditor = Object.freeze({
+const booleanEditor = Object.freeze({
id: 'boolean',
priority: 200,
- supports: context => namedType(context.inputType) === 'Boolean',
- render: context => `<input type="checkbox" ${inputAttributes(context)}${context.value === true ? ' checked' : ''}>`,
- parse: ({checked}) => Boolean(checked)
+ supports: context => semanticTypeName(context) === 'Boolean',
+ render: context => context.required
+ ? `<input type="checkbox" ${inputAttributes(context)}${context.controlValue === 'true' ? ' checked' : ''}>`
+ : `<select ${inputAttributes(context)}><option value=""${context.controlValue === '' ? ' selected' : ''}>No value</option><option value="true"${context.controlValue === 'true' ? ' selected' : ''}>True</option><option value="false"${context.controlValue === 'false' ? ' selected' : ''}>False</option></select>`,
+ parse: context => context.required ? Boolean(context.checked) : context.value
});
const temporalEditor = Object.freeze({
id: 'temporal',
priority: 195,
- supports: context => ['LocalDate', 'LocalDateTime', 'LocalTime'].includes(namedType(context.inputType)),
+ supports: context => [
+ 'LocalDate', 'LocalDateTime', 'LocalTime',
+ 'OffsetDateTime', 'OffsetTime', 'DateTime', 'LegacyDateTime', 'ZonedDateTime'
+ ].includes(semanticTypeName(context)),
render: context => {
- const typeName = namedType(context.inputType);
- const inputType = typeName === 'LocalDate' ? 'date' : typeName === 'LocalTime' ? 'time' : 'datetime-local';
- const step = typeName === 'LocalDate' ? '' : ' step="1"';
- return `<input type="${inputType}" ${inputAttributes(context)} value="${escapeHtml(context.value ?? '')}"${step}>`;
+ const typeName = semanticTypeName(context);
+ const inputType = typeName === 'LocalDate'
+ ? 'date'
+ : typeName === 'LocalTime' ? 'time' : typeName === 'LocalDateTime' ? 'datetime-local' : 'text';
+ const step = ['LocalTime', 'LocalDateTime'].includes(typeName) ? ' step="any"' : '';
+ return `<input type="${inputType}" ${inputAttributes(context)} value="${escapeHtml(context.controlValue)}"${step}>`;
},
- parse: ({value}) => value || null
+ parse: ({value}) => value
+});
+
+const exactNumberEditor = Object.freeze({
+ id: 'exact-number',
+ priority: 192,
+ supports: context => ['BigDecimal', 'BigInteger', 'Long'].includes(semanticTypeName(context)),
+ render: context => `<input type="text" inputmode="${semanticTypeName(context) === 'BigDecimal' ? 'decimal' : 'numeric'}" ${inputAttributes(context)} value="${escapeHtml(context.controlValue)}">`,
+ parse: ({value}) => value
});
const numberEditor = Object.freeze({
id: 'number',
priority: 190,
- supports: context => ['Int', 'Float', 'BigDecimal', 'BigInteger', 'Long'].includes(namedType(context.inputType)),
- render: context => `<input type="number" ${inputAttributes(context)} value="${escapeHtml(context.value ?? '')}">`,
- parse: ({value, inputType}) => {
- if (value === '') {
- return null;
- }
- return namedType(inputType) === 'Int' ? Number.parseInt(value, 10) : Number(value);
- }
+ supports: context => ['Int', 'Short', 'Byte', 'Float', 'Double'].includes(semanticTypeName(context)),
+ render: context => `<input type="number" ${inputAttributes(context)} value="${escapeHtml(context.controlValue)}">`,
+ parse: ({value}) => value
});
const multilineEditor = Object.freeze({
id: 'multiline',
priority: 110,
- supports: context => namedType(context.inputType) === 'String' && Number.isSafeInteger(context.multiLine) && context.multiLine > 1,
+ supports: context => semanticTypeName(context) === 'String' && Number.isSafeInteger(context.multiLine) && context.multiLine > 1,
render: context => `<textarea ${inputAttributes(context)} rows="${Math.min(context.multiLine, 50)}">${escapeHtml(context.value ?? '')}</textarea>`,
parse: ({value}) => value
});
@@ -143,8 +153,15 @@ const multilineEditor = Object.freeze({
const textEditor = Object.freeze({
id: 'text',
priority: 100,
- supports: context => ['String', 'ID'].includes(namedType(context.inputType)),
- render: context => `<input type="text" ${inputAttributes(context)} value="${escapeHtml(context.value ?? '')}">`,
+ supports: context => ['String', 'ID', 'UUID', 'Locale', 'Char', 'URL', 'Url', 'Password', 'ProtectedValue'].includes(semanticTypeName(context)),
+ render: context => {
+ const typeName = semanticTypeName(context);
+ const inputType = ['Password', 'ProtectedValue'].includes(typeName)
+ ? 'password'
+ : ['URL', 'Url'].includes(typeName) ? 'url' : 'text';
+ const autocomplete = inputType === 'password' ? ' autocomplete="new-password"' : '';
+ return `<input type="${inputType}"${autocomplete} ${inputAttributes(context)} value="${escapeHtml(context.controlValue)}">`;
+ },
parse: ({value}) => value
});
@@ -163,7 +180,7 @@ const unsupportedEditor = Object.freeze({
id: 'unsupported',
priority: -1000,
supports: () => true,
- render: context => `<span class="causeway-unsupported" role="alert">Unsupported editor for ${escapeHtml(namedType(context.inputType) ?? 'unknown input type')}</span>`,
+ render: context => `<span class="causeway-unsupported" role="alert">Unsupported editor for ${escapeHtml(semanticTypeName(context) ?? 'unknown input type')}</span>`,
parse: ({value}) => value
});
@@ -173,17 +190,37 @@ export const defaultEditorRegistry = new CausewayEditorRegistry([
autoCompleteEditor,
booleanEditor,
temporalEditor,
+ exactNumberEditor,
numberEditor,
enumEditor,
multilineEditor,
textEditor
]);
-export function renderCausewayEditor(context, registry = defaultEditorRegistry) {
- const editor = registry.select(context);
+export function renderCausewayEditor(context, registry = defaultEditorRegistry, codecRegistry = defaultValueCodecRegistry) {
+ const codec = codecRegistry.select(context);
+ const selectedEditor = codec.id === 'unsupported' ? unsupportedEditor : registry.select(context);
+ const editor = Object.freeze({
+ ...selectedEditor,
+ codec,
+ render: currentContext => {
+ const effectiveContext = {...context, ...currentContext};
+ return selectedEditor.render({
+ ...effectiveContext,
+ controlValue: codec.toControlValue(effectiveContext.value, effectiveContext)
+ });
+ },
+ parse: currentContext => {
+ const effectiveContext = {...context, ...currentContext};
+ const rawValue = selectedEditor.parse(effectiveContext);
+ return parseCausewayValue(codec, {...effectiveContext, value: rawValue});
+ }
+ });
return Object.freeze({
editor,
editorId: editor.id,
+ codec,
+ codecId: codec.id,
html: editor.render(context)
});
}
diff --git a/viewers/webcomponents/foundation/src/index.mjs b/viewers/webcomponents/foundation/src/index.mjs
index 30ccc8a1daf..90b2c155167 100644
--- a/viewers/webcomponents/foundation/src/index.mjs
+++ b/viewers/webcomponents/foundation/src/index.mjs
@@ -50,6 +50,7 @@ export * from './service-action-context.mjs';
export * from './structural-resource.mjs';
export * from './structural-xml.mjs';
export * from './types.mjs';
+export * from './value-codecs.mjs';
export * from './value-element.mjs';
export * from './value-renderers.mjs';
diff --git a/viewers/webcomponents/foundation/src/interaction-controller-element.mjs b/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
index b6afb600c7d..fc7b0425af4 100644
--- a/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
+++ b/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
@@ -23,6 +23,7 @@ import {defaultEditorRegistry, parseCausewayEditorValue, renderCausewayEditor} f
import {causewayReferenceWidgetConfiguration} from './reference-widget.mjs';
import {escapeHtml} from './rendering.mjs';
import {InteractionStatus} from './types.mjs';
+import {CausewayValueCodecError, semanticTypeName} from './value-codecs.mjs';
let controllerSequence = 0;
@@ -422,26 +423,44 @@ export class CausewayInteractionControllerElement extends HTMLElement {
}
const choices = parameter.state?.choices ?? parameter.enumValues ?? [];
const rendered = renderCausewayEditor(this.#parameterEditorContext(parameter, choices), this._editorRegistry);
- const value = parseCausewayEditorValue(rendered.editor, {
- value: event.target.value,
- checked: event.target.checked,
- choices,
- suggestions: parameter.state?.suggestions ?? [],
- inputType: parameter.inputType
- });
- const textEditor = rendered.editor.id === 'text';
- if (textEditor) {
+ try {
+ const value = parseCausewayEditorValue(rendered.editor, {
+ value: event.target.value,
+ checked: event.target.checked,
+ choices,
+ suggestions: parameter.state?.suggestions ?? [],
+ inputType: parameter.inputType
+ });
+ const textEditor = rendered.editor.id === 'text';
+ if (textEditor) {
+ this.promptState = Object.freeze({
+ ...this.promptState,
+ values: Object.freeze({...this.promptState.values, [parameterId]: value}),
+ error: null,
+ status: InteractionStatus.EDITING
+ });
+ clearTimeout(this.parameterTimer);
+ this.parameterTimer = setTimeout(() => void this.setParameterValue(parameterId, value), 250);
+ return;
+ }
+ void this.setParameterValue(parameterId, value);
+ } catch (error) {
+ if (!(error instanceof CausewayValueCodecError)) {
+ throw error;
+ }
+ const parameters = Object.freeze(this.promptState.parameters.map(candidate => candidate.id === parameterId
+ ? Object.freeze({...candidate, state: Object.freeze({...candidate.state, error: error.message})})
+ : candidate));
this.promptState = Object.freeze({
...this.promptState,
- values: Object.freeze({...this.promptState.values, [parameterId]: value}),
- error: null,
- status: InteractionStatus.EDITING
+ values: Object.freeze({...this.promptState.values, [parameterId]: event.target.value}),
+ parameters,
+ status: InteractionStatus.FAILED,
+ error: error.message
});
- clearTimeout(this.parameterTimer);
- this.parameterTimer = setTimeout(() => void this.setParameterValue(parameterId, value), 250);
- return;
+ this.#publishPromptState();
+ this.render();
}
- void this.setParameterValue(parameterId, value);
}
#promptMarkup() {
@@ -454,8 +473,9 @@ export class CausewayInteractionControllerElement extends HTMLElement {
}
const parameterMarkup = state.parameters.map(parameter => this.#parameterMarkup(parameter)).join('');
const busy = [InteractionStatus.VALIDATING, InteractionStatus.INVOKING].includes(state.status);
- const errorMarkup = state.error
- ? `<div id="${this.errorId}" class="causeway-action-prompt-error" role="alert">${escapeHtml(state.error)}</div>`
+ const publicError = protectedPromptText(state, state.error);
+ const errorMarkup = publicError
+ ? `<div id="${this.errorId}" class="causeway-action-prompt-error" role="alert">${escapeHtml(publicError)}</div>`
: '';
return `<dialog open class="causeway-action-prompt" role="dialog" aria-modal="true" aria-labelledby="${this.titleId}"${state.error ? ` aria-describedby="${this.errorId}"` : ''} data-testid="action-prompt">
<form method="dialog" novalidate>
@@ -482,7 +502,8 @@ export class CausewayInteractionControllerElement extends HTMLElement {
const description = this.#parameterDescription(parameter, label);
const disabledReason = typeof parameter.state?.disabled === 'string' ? parameter.state.disabled : '';
const validityReason = typeof parameter.state?.validity === 'string' ? parameter.state.validity : '';
- const reason = parameter.state?.error || validityReason || disabledReason;
+ const reason = protectedPromptText(this.promptState,
+ parameter.state?.error || validityReason || disabledReason);
return `<div class="causeway-action-parameter${reason ? ' causeway-error' : ''}" data-parameter="${escapeHtml(parameter.id)}">
<label id="${editorContext.labelId}" for="${editorContext.inputId}">${escapeHtml(label)}</label>
${description ? `<span id="${editorContext.descriptionId}" class="causeway-action-parameter-description">${escapeHtml(description)}</span>` : ''}
@@ -505,6 +526,7 @@ export class CausewayInteractionControllerElement extends HTMLElement {
autoComplete: parameter.fields.has('autoComplete'),
enumValues: parameter.enumValues,
inputType: parameter.inputType,
+ semanticType: parameter.state?.datatype ?? null,
required: parameter.inputType?.kind === 'NON_NULL',
inputId: `causeway-action-parameter-${parameter.id}`,
labelId: `causeway-action-parameter-${parameter.id}-label`,
@@ -534,8 +556,8 @@ export class CausewayInteractionControllerElement extends HTMLElement {
this.dispatchEvent(createSemanticEvent(CausewaySemanticEvent.ACTION_PROMPT_STATE, Object.freeze({
actionId: this.promptState.actionId,
status: this.promptState.status,
- values: this.promptState.values,
- error: this.promptState.error,
+ values: publicPromptValues(this.promptState),
+ error: protectedPromptText(this.promptState, this.promptState.error),
...interactionTargetDetail(this.promptState.context)
})));
}
@@ -633,6 +655,29 @@ function interactionTargetDetail(context) {
});
}
+function publicPromptValues(state) {
+ const sensitiveIds = new Set((state.parameters ?? [])
+ .filter(parameter => ['Password', 'ProtectedValue'].includes(semanticTypeName({semanticType: parameter.state?.datatype})))
+ .map(parameter => parameter.id));
+ return Object.freeze(Object.fromEntries(Object.entries(state.values ?? {})
+ .map(([name, value]) => [name, sensitiveIds.has(name) ? null : value])));
+}
+
+function protectedPromptText(state, text) {
+ let result = String(text ?? '');
+ if (!result) {
+ return result;
+ }
+ const publicValues = publicPromptValues(state);
+ for (const [name, value] of Object.entries(state.values ?? {})) {
+ if (publicValues[name] !== null || typeof value !== 'string' || value.length === 0) {
+ continue;
+ }
+ result = result.split(value).join('[protected]');
+ }
+ return result;
+}
+
function promptStatusLabel(status) {
return {
[InteractionStatus.EDITING]: 'Enter action parameters',
diff --git a/viewers/webcomponents/foundation/src/property-element.mjs b/viewers/webcomponents/foundation/src/property-element.mjs
index 5a69e5a89bc..9d11b47f9a5 100644
--- a/viewers/webcomponents/foundation/src/property-element.mjs
+++ b/viewers/webcomponents/foundation/src/property-element.mjs
@@ -28,6 +28,7 @@ import {
import {causewayReferenceWidgetConfiguration} from './reference-widget.mjs';
import {errorMessage, escapeHtml} from './rendering.mjs';
import {InteractionStatus} from './types.mjs';
+import {CausewayValueCodecError} from './value-codecs.mjs';
import {defaultValueRendererRegistry, renderCausewayValue} from './value-renderers.mjs';
let propertySequence = 0;
@@ -229,12 +230,13 @@ export class CausewayPropertyElement extends CausewayContextConsumerElement {
this.autoCompleteController = null;
this.autoCompleteGeneration += 1;
this.interactionGeneration += 1;
+ const editor = this.interactionState.editor;
this.interactionState = null;
this.restoreEditFocusAfterGeneration = null;
this.dispatchEvent(createSemanticEvent(CausewaySemanticEvent.PROPERTY_INTERACTION_STATE, Object.freeze({
member: this.member,
status: InteractionStatus.CANCELLED,
- value: this.componentState?.data?.get
+ value: publicInteractionValue(editor, this.componentState?.data?.get)
})));
this.renderComponentState(this.componentState);
queueMicrotask(() => this.querySelector?.('[data-causeway-action="edit"]')?.focus?.());
@@ -252,7 +254,7 @@ export class CausewayPropertyElement extends CausewayContextConsumerElement {
this.dispatchEvent(createSemanticEvent(CausewaySemanticEvent.PROPERTY_INTERACTION_STATE, Object.freeze({
member: this.member,
status: InteractionStatus.EDITING,
- value,
+ value: publicInteractionValue(this.interactionState.editor, value),
error: null
})));
if (validate) {
@@ -358,10 +360,11 @@ export class CausewayPropertyElement extends CausewayContextConsumerElement {
return false;
}
this.restoreEditFocusAfterGeneration = focusGeneration;
+ const editor = this.interactionState.editor;
this.interactionState = null;
this.dispatchEvent(createSemanticEvent(CausewaySemanticEvent.PROPERTY_UPDATED, Object.freeze({
member: this.member,
- value: pendingValue,
+ value: publicInteractionValue(editor, pendingValue),
identity: this._resolvedContext?.identity ?? null,
result
})));
@@ -426,17 +429,29 @@ export class CausewayPropertyElement extends CausewayContextConsumerElement {
return;
}
const editor = this.interactionState.editor;
- const value = parseCausewayEditorValue(editor, {
- value: event.target.value,
- checked: event.target.checked,
- choices: this.interactionState.choices ?? [],
- suggestions: this.interactionState.suggestions ?? [],
- inputType: this.interactionState.capabilities?.inputType
- });
- const textEditor = ['text', 'multiline'].includes(editor.id);
- this.setPendingValue(value, {validate: textEditor});
- if (!textEditor) {
- void this.validatePending();
+ try {
+ const value = parseCausewayEditorValue(editor, {
+ value: event.target.value,
+ checked: event.target.checked,
+ choices: this.interactionState.choices ?? [],
+ suggestions: this.interactionState.suggestions ?? [],
+ inputType: this.interactionState.capabilities?.inputType
+ });
+ const textEditor = ['text', 'multiline'].includes(editor.id);
+ this.setPendingValue(value, {validate: textEditor});
+ if (!textEditor) {
+ void this.validatePending();
+ }
+ } catch (error) {
+ if (!(error instanceof CausewayValueCodecError)) {
+ throw error;
+ }
+ this.#setInteraction({
+ ...this.interactionState,
+ status: InteractionStatus.FAILED,
+ pendingValue: event.target.value,
+ error: error.message
+ });
}
}
@@ -489,6 +504,7 @@ export class CausewayPropertyElement extends CausewayContextConsumerElement {
autoComplete: interaction.capabilities?.autoComplete === true,
enumValues: interaction.capabilities?.enumValues ?? [],
inputType: interaction.capabilities?.inputType,
+ semanticType: this.componentState?.data?.datatype ?? interaction.capabilities?.semanticType ?? null,
required: interaction.capabilities?.inputType?.kind === 'NON_NULL',
multiLine: this.multiLine,
inputId: this.inputId,
@@ -552,7 +568,7 @@ export class CausewayPropertyElement extends CausewayContextConsumerElement {
this.dispatchEvent(createSemanticEvent(CausewaySemanticEvent.PROPERTY_INTERACTION_STATE, Object.freeze({
member: this.member,
status: next.status,
- value: next.pendingValue,
+ value: publicInteractionValue(next.editor, next.pendingValue),
error: next.error ?? null
})));
this.renderComponentState(this.componentState);
@@ -569,6 +585,10 @@ export class CausewayPropertyElement extends CausewayContextConsumerElement {
}
}
+function publicInteractionValue(editor, value) {
+ return editor?.codec?.sensitive === true ? null : value;
+}
+
function boundedTooltip(value, maximum = 240) {
const text = String(value ?? '').trim();
return text.length > maximum ? `${text.slice(0, maximum - 1)}…` : text;
diff --git a/viewers/webcomponents/foundation/src/value-codecs.mjs b/viewers/webcomponents/foundation/src/value-codecs.mjs
new file mode 100644
index 00000000000..18ad5808d03
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/value-codecs.mjs
@@ -0,0 +1,382 @@
+/*
+ * 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
+ *
+ * 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.
+ */
+
+import {namedType} from './introspection.mjs';
+
+const INTEGER_PATTERN = /^[+-]?\d+$/;
+const DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
+const LOCAL_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
+const LOCAL_TIME_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?$/;
+const OFFSET_PATTERN = /(?:Z|[+-](?:0\d|1\d|2[0-3]):[0-5]\d)$/;
+const ZONED_DATE_TIME_PATTERN = /^(.+)(?:Z|[+-](?:0\d|1\d|2[0-3]):[0-5]\d)\[([^\]]+)]$/;
+const LONG_MIN = -9223372036854775808n;
+const LONG_MAX = 9223372036854775807n;
+const INT_MIN = -2147483648;
+const INT_MAX = 2147483647;
+
+export class CausewayValueCodecError extends Error {
+ constructor(message, code = 'INVALID_VALUE') {
+ super(message);
+ this.name = 'CausewayValueCodecError';
+ this.code = code;
+ }
+}
+
+export function semanticTypeName(context) {
+ const advertised = String(context?.semanticType ?? '').trim();
+ if (advertised) {
+ const typeName = advertised.split('_').filter(Boolean).at(-1) ?? advertised;
+ return {
+ int: 'Int', Integer: 'Int',
+ long: 'Long',
+ boolean: 'Boolean',
+ byte: 'Byte',
+ short: 'Short',
+ float: 'Float',
+ double: 'Double',
+ char: 'Char', Character: 'Char'
+ }[typeName] ?? typeName;
+ }
+ return namedType(context?.inputType);
+}
+
+export class CausewayValueCodecRegistry {
+ constructor(registrations = []) {
+ this.registrations = [];
+ registrations.forEach(registration => this.register(registration));
+ }
+
+ register(registration) {
+ if (!registration?.id || typeof registration.supports !== 'function' || typeof registration.parse !== 'function') {
+ throw new Error('A value codec registration requires id, supports, and parse.');
+ }
+ const normalized = Object.freeze({
+ priority: 0,
+ normalize: value => value,
+ toControlValue: value => value ?? '',
+ toGraphQLValue: value => value,
+ ...registration
+ });
+ this.registrations.push(normalized);
+ this.registrations.sort((left, right) => right.priority - left.priority);
+ return () => {
+ const index = this.registrations.indexOf(normalized);
+ if (index >= 0) {
+ this.registrations.splice(index, 1);
+ }
+ };
+ }
+
+ select(context) {
+ return this.registrations.find(registration => registration.supports(context)) ?? unsupportedValueCodec;
+ }
+}
+
+const exactNumericCodec = Object.freeze({
+ id: 'exact-numeric',
+ priority: 300,
+ supports: context => ['Long', 'BigInteger', 'BigDecimal'].includes(semanticTypeName(context)),
+ normalize: (value, context) => value == null ? null : parseExactNumeric(String(value), context),
+ toControlValue: value => value ?? '',
+ parse: context => parseExactNumeric(context.value, context),
+ toGraphQLValue: value => value
+});
+
+const machineNumericCodec = Object.freeze({
+ id: 'machine-numeric',
+ priority: 290,
+ supports: context => ['Int', 'Short', 'Byte', 'Float', 'Double'].includes(semanticTypeName(context)),
+ normalize: (value, context) => value == null ? null : parseMachineNumeric(String(value), context),
+ toControlValue: value => value ?? '',
+ parse: context => parseMachineNumeric(context.value, context),
+ toGraphQLValue: value => value
+});
+
+const booleanCodec = Object.freeze({
+ id: 'boolean',
+ priority: 280,
+ supports: context => semanticTypeName(context) === 'Boolean',
+ normalize: value => value == null ? null : Boolean(value),
+ toControlValue: value => value == null ? '' : String(Boolean(value)),
+ parse: context => {
+ if (context.required) {
+ return Boolean(context.checked);
+ }
+ if (context.value === '' || context.value == null) {
+ return null;
+ }
+ if (context.value === true || context.value === 'true') {
+ return true;
+ }
+ if (context.value === false || context.value === 'false') {
+ return false;
+ }
+ throw new CausewayValueCodecError('Choose true, false, or no value.');
+ },
+ toGraphQLValue: value => value
+});
+
+const temporalCodec = Object.freeze({
+ id: 'temporal',
+ priority: 270,
+ supports: context => [
+ 'LocalDate', 'LocalTime', 'LocalDateTime',
+ 'OffsetTime', 'OffsetDateTime', 'DateTime', 'LegacyDateTime', 'ZonedDateTime'
+ ].includes(semanticTypeName(context)),
+ normalize: (value, context) => value == null ? null : parseTemporal(String(value), context),
+ toControlValue: value => value ?? '',
+ parse: context => parseTemporal(context.value, context),
+ toGraphQLValue: value => value
+});
+
+const protectedCodec = Object.freeze({
+ id: 'protected',
+ priority: 265,
+ sensitive: true,
+ supports: context => ['Password', 'ProtectedValue'].includes(semanticTypeName(context)),
+ normalize: () => null,
+ toControlValue: () => '',
+ parse: context => requiredValue(context.value, context),
+ toGraphQLValue: value => value
+});
+
+const urlCodec = Object.freeze({
+ id: 'url',
+ priority: 260,
+ supports: context => ['URL', 'Url'].includes(semanticTypeName(context)),
+ normalize: (value, context) => value == null ? null : parseUrl(String(value), context),
+ toControlValue: value => value ?? '',
+ parse: context => parseUrl(context.value, context),
+ toGraphQLValue: value => value
+});
+
+const scalarCodec = Object.freeze({
+ id: 'scalar',
+ priority: 100,
+ supports: context => ['String', 'ID', 'UUID', 'Locale', 'Char'].includes(semanticTypeName(context))
+ || innermostType(context.inputType)?.kind === 'ENUM',
+ normalize: value => value,
+ toControlValue: value => value ?? '',
+ parse: context => requiredValue(context.value, context),
+ toGraphQLValue: value => value
+});
+
+const referenceCodec = Object.freeze({
+ id: 'reference',
+ priority: 90,
+ supports: context => ['OBJECT', 'INTERFACE', 'UNION', 'LIST', 'INPUT_OBJECT'].includes(innermostType(context.inputType)?.kind)
+ || semanticTypeName(context)?.startsWith('rich__')
+ || semanticTypeName(context)?.startsWith('simple__'),
+ normalize: value => value,
+ toControlValue: value => value,
+ parse: context => context.value,
+ toGraphQLValue: value => value
+});
+
+const unsupportedValueCodec = Object.freeze({
+ id: 'unsupported',
+ priority: -1000,
+ supports: () => true,
+ normalize: value => value,
+ toControlValue: () => '',
+ parse: context => {
+ throw new CausewayValueCodecError(
+ `No reversible input codec is registered for '${semanticTypeName(context) ?? 'unknown input type'}'.`,
+ 'UNSUPPORTED_VALUE_CODEC');
+ },
+ toGraphQLValue: () => {
+ throw new CausewayValueCodecError('Unsupported values cannot be submitted.', 'UNSUPPORTED_VALUE_CODEC');
+ }
+});
+
+const unsupportedResourceCodec = Object.freeze({
+ ...unsupportedValueCodec,
+ priority: 400,
+ supports: context => ['Blob', 'Clob', 'LocalResourcePath', 'LocalResourcePathValue', 'LocalResourcePathInput']
+ .includes(semanticTypeName(context))
+});
+
+export const defaultValueCodecRegistry = new CausewayValueCodecRegistry([
+ unsupportedResourceCodec,
+ exactNumericCodec,
+ machineNumericCodec,
+ booleanCodec,
+ temporalCodec,
+ protectedCodec,
+ urlCodec,
+ scalarCodec,
+ referenceCodec
+]);
+
+export function selectCausewayValueCodec(context, registry = defaultValueCodecRegistry) {
+ return registry.select(context);
+}
+
+export function parseCausewayValue(codec, context) {
+ const parsed = codec.parse(context);
+ return codec.toGraphQLValue(parsed, context);
+}
+
+function parseExactNumeric(value, context) {
+ const lexical = nullableLexical(value, context);
+ if (lexical == null) {
+ return null;
+ }
+ const typeName = semanticTypeName(context);
+ if (typeName === 'BigDecimal') {
+ if (!DECIMAL_PATTERN.test(lexical)) {
+ throw new CausewayValueCodecError('Enter an exact decimal number.');
+ }
+ return lexical;
+ }
+ if (!INTEGER_PATTERN.test(lexical)) {
+ throw new CausewayValueCodecError('Enter a whole number.');
+ }
+ if (typeName === 'Long') {
+ const valueAsBigInt = BigInt(lexical);
+ if (valueAsBigInt < LONG_MIN || valueAsBigInt > LONG_MAX) {
+ throw new CausewayValueCodecError('Enter a whole number within the signed 64-bit range.');
+ }
+ }
+ return lexical;
+}
+
+function parseMachineNumeric(value, context) {
+ const lexical = nullableLexical(value, context);
+ if (lexical == null) {
+ return null;
+ }
+ const typeName = semanticTypeName(context);
+ if (['Int', 'Short', 'Byte'].includes(typeName)) {
+ if (!INTEGER_PATTERN.test(lexical)) {
+ throw new CausewayValueCodecError('Enter a whole number.');
+ }
+ const parsed = Number(lexical);
+ const [minimum, maximum] = typeName === 'Byte'
+ ? [-128, 127]
+ : typeName === 'Short' ? [-32768, 32767] : [INT_MIN, INT_MAX];
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
+ throw new CausewayValueCodecError(`Enter a whole number from ${minimum} to ${maximum}.`);
+ }
+ return parsed;
+ }
+ const parsed = Number(lexical);
+ if (!Number.isFinite(parsed)) {
+ throw new CausewayValueCodecError('Enter a finite number.');
+ }
+ return parsed;
+}
+
+function parseTemporal(value, context) {
+ const lexical = nullableLexical(value, context);
+ if (lexical == null) {
+ return null;
+ }
+ const typeName = semanticTypeName(context);
+ const valid = typeName === 'LocalDate'
+ ? validLocalDate(lexical)
+ : typeName === 'LocalTime'
+ ? LOCAL_TIME_PATTERN.test(lexical)
+ : typeName === 'LocalDateTime'
+ ? validLocalDateTime(lexical)
+ : typeName === 'OffsetTime'
+ ? validOffsetTime(lexical)
+ : typeName === 'ZonedDateTime'
+ ? validZonedDateTime(lexical)
+ : validOffsetDateTime(lexical);
+ if (!valid) {
+ throw new CausewayValueCodecError(`Enter a valid ${typeName} value.`);
+ }
+ return lexical;
+}
+
+function parseUrl(value, context) {
+ const lexical = nullableLexical(value, context);
+ if (lexical == null) {
+ return null;
+ }
+ try {
+ const parsed = new URL(lexical);
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ throw new Error('unsupported protocol');
+ }
+ } catch {
+ throw new CausewayValueCodecError('Enter an absolute HTTP or HTTPS URL.');
+ }
+ return lexical;
+}
+
+function nullableLexical(value, context) {
+ const lexical = String(value ?? '');
+ if (lexical === '') {
+ if (context.required) {
+ throw new CausewayValueCodecError('A value is required.', 'REQUIRED_VALUE');
+ }
+ return null;
+ }
+ return lexical;
+}
+
+function requiredValue(value, context) {
+ if ((value == null || value === '') && context.required) {
+ throw new CausewayValueCodecError('A value is required.', 'REQUIRED_VALUE');
+ }
+ return value == null || value === '' ? null : value;
+}
+
+function validLocalDate(value) {
+ const match = LOCAL_DATE_PATTERN.exec(value);
+ if (!match) {
+ return false;
+ }
+ const year = Number(match[1]);
+ const month = Number(match[2]);
+ const day = Number(match[3]);
+ const date = new Date(Date.UTC(year, month - 1, day));
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
+}
+
+function validLocalDateTime(value) {
+ const separator = value.indexOf('T');
+ return separator > 0 && validLocalDate(value.slice(0, separator)) && LOCAL_TIME_PATTERN.test(value.slice(separator + 1));
+}
+
+function validOffsetTime(value) {
+ const offsetStart = Math.max(value.lastIndexOf('+'), value.lastIndexOf('-'), value.endsWith('Z') ? value.length - 1 : -1);
+ return offsetStart > 0 && LOCAL_TIME_PATTERN.test(value.slice(0, offsetStart)) && OFFSET_PATTERN.test(value.slice(offsetStart));
+}
+
+function validOffsetDateTime(value) {
+ const zoneStart = Math.max(value.lastIndexOf('+'), value.lastIndexOf('-'), value.endsWith('Z') ? value.length - 1 : -1);
+ return zoneStart > 10 && validLocalDateTime(value.slice(0, zoneStart)) && OFFSET_PATTERN.test(value.slice(zoneStart));
+}
+
+function validZonedDateTime(value) {
+ const match = ZONED_DATE_TIME_PATTERN.exec(value);
+ const bracket = value.lastIndexOf('[');
+ return Boolean(match?.[2]) && bracket > 0 && validOffsetDateTime(value.slice(0, bracket));
+}
+
+function innermostType(typeRef) {
+ let current = typeRef;
+ while (current?.ofType) {
+ current = current.ofType;
+ }
+ return current;
+}
diff --git a/viewers/webcomponents/foundation/test/interaction.test.mjs b/viewers/webcomponents/foundation/test/interaction.test.mjs
index 0f879a74b34..fe0c30e38ea 100644
--- a/viewers/webcomponents/foundation/test/interaction.test.mjs
+++ b/viewers/webcomponents/foundation/test/interaction.test.mjs
@@ -123,7 +123,7 @@ test('editor registry selects standard inputs and supports application overrides
});
assert.equal(dateTime.editorId, 'temporal');
assert.match(dateTime.html, /type="datetime-local"/);
- assert.match(dateTime.html, /step="1"/);
+ assert.match(dateTime.html, /step="any"/);
assert.equal(parseCausewayEditorValue(dateTime.editor, {value: '2026-08-21T10:15:00'}), '2026-08-21T10:15:00');
const choices = renderCausewayEditor({
@@ -138,6 +138,68 @@ test('editor registry selects standard inputs and supports application overrides
assert.equal(registry.select({name: 'name'}).id, 'custom-name');
});
+test('property interactions preserve exact decimal lexical state and map codec errors locally', async () => {
+ let stateListener;
+ const calls = [];
+ const context = {
+ registerRequirement(requirement, listener) {
+ stateListener = listener;
+ return () => {};
+ },
+ async prepareProperty() {
+ return {
+ status: 'success', errors: [], data: {
+ capabilities: {validate: true, inputType: scalar('String'), enumValues: []},
+ choices: []
+ }
+ };
+ },
+ async validateProperty(member, value) {
+ calls.push(`validate:${value}`);
+ return {status: 'success', data: null, errors: []};
+ },
+ async updateProperty(member, value) {
+ calls.push(`update:${value}`);
+ return {status: 'success', data: {_meta: {id: '42'}}, errors: []};
+ }
+ };
+ const property = new CausewayPropertyElement();
+ property.member = 'amount';
+ property.editable = true;
+ property.context = context;
+ document.body.appendChild(property);
+ stateListener({
+ status: 'ready',
+ descriptor: {id: 'amount', value: {typeRef: scalar('String')}},
+ data: {hidden: false, disabled: null, datatype: 'rich__java_math_BigDecimal', get: '1.2300'},
+ errors: [], generation: 1
+ });
+ assert.equal(await property.beginEdit(), true);
+ assert.equal(property.getAttribute('data-editor'), 'exact-number');
+ assert.match(property.innerHTML, /value="1.2300"/);
+
+ const invalid = new Event('input');
+ invalid.target = {
+ getAttribute: name => name === 'data-causeway-editor' ? 'amount' : null,
+ value: '1.2.3',
+ checked: false
+ };
+ property.dispatchEvent(invalid);
+ assert.equal(property.interactionState.status, 'failed');
+ assert.match(property.interactionState.error, /exact decimal/);
+ assert.equal(property.interactionState.pendingValue, '1.2.3');
+ assert.deepEqual(calls, []);
+
+ const corrected = new Event('input');
+ corrected.target = {...invalid.target, value: '9007199254740993.1200'};
+ property.dispatchEvent(corrected);
+ await new Promise(resolve => setTimeout(resolve, 0));
+ assert.equal(property.interactionState.pendingValue, '9007199254740993.1200');
+ assert.ok(calls.includes('validate:9007199254740993.1200'));
+ property.cancelEdit();
+ assert.equal(calls.some(call => call.startsWith('update:')), false);
+});
+
test('opt-in reference widgets preserve identities, multi values, bounds and fallback', () => {
const references = [
{_meta: {id: 'owner-1', logicalTypeName: 'example.Owner', title: 'Owner One'}},
@@ -367,6 +429,106 @@ test('standard action controller renders prompts, blocks invalid input, invokes
assert.ok(calls.includes('invoke:Updated'));
});
+test('action parameters preserve exact integer defaults and reject malformed values before preparation', async () => {
+ const calls = [];
+ const parameter = value => ({
+ id: 'amount', description: 'Amount', inputType: scalar('String'), enumValues: [], fields: new Map(),
+ state: {
+ hidden: false,
+ disabled: null,
+ datatype: 'rich__java_math_BigInteger',
+ default: value,
+ validity: null
+ }
+ });
+ const context = {
+ identity: {logicalTypeName: 'example.Object', id: '42'},
+ async prepareAction(actionId, values) {
+ calls.push(`prepare:${JSON.stringify(values)}`);
+ return {status: 'success', errors: [], data: {parameters: [parameter(values.amount ?? '9007199254740993')]}};
+ },
+ async validateAction(actionId, values) {
+ calls.push(`validate:${values.amount}`);
+ return {status: 'success', data: null, errors: []};
+ },
+ async invokeAction(actionId, values) {
+ calls.push(`invoke:${values.amount}`);
+ return {status: 'success', data: {kind: 'scalar', value: values.amount}, errors: []};
+ }
+ };
+ const controller = new CausewayInteractionControllerElement();
+ document.body.appendChild(controller);
+ assert.equal(await controller.beginAction('changeAmount', context), true);
+ assert.match(controller.innerHTML, /value="9007199254740993"/);
+ const preparationCount = calls.filter(call => call.startsWith('prepare:')).length;
+
+ const invalid = new Event('input');
+ invalid.target = {
+ getAttribute: name => name === 'data-causeway-editor' ? 'amount' : null,
+ value: '12.5',
+ checked: false
+ };
+ controller.dispatchEvent(invalid);
+ assert.equal(controller.promptState.status, 'failed');
+ assert.match(controller.promptState.error, /whole number/);
+ assert.equal(calls.filter(call => call.startsWith('prepare:')).length, preparationCount);
+
+ const corrected = new Event('input');
+ corrected.target = {...invalid.target, value: '123456789012345678901234567890'};
+ controller.dispatchEvent(corrected);
+ await new Promise(resolve => setTimeout(resolve, 0));
+ assert.equal(controller.promptState.values.amount, '123456789012345678901234567890');
+ assert.equal(await controller.submitPrompt(), true);
+ assert.ok(calls.includes('validate:123456789012345678901234567890'));
+ assert.ok(calls.includes('invoke:123456789012345678901234567890'));
+});
+
+test('protected action values remain write-only in markup and semantic prompt events', async () => {
+ const published = [];
+ const parameter = validity => ({
+ id: 'password', description: 'Password', inputType: scalar('String'), enumValues: [], fields: new Map(),
+ state: {
+ hidden: false,
+ disabled: null,
+ datatype: 'rich__causeway_applib_value_Password',
+ default: null,
+ validity
+ }
+ });
+ const context = {
+ identity: {logicalTypeName: 'example.Object', id: '42'},
+ async prepareAction(actionId, values) {
+ const secret = values.password;
+ return {status: 'success', errors: [], data: {parameters: [parameter(secret ? `Rejected ${secret}` : null)]}};
+ },
+ async validateAction() {
+ return {status: 'success', data: null, errors: []};
+ },
+ async invokeAction() {
+ return {status: 'success', data: {kind: 'void'}, errors: []};
+ }
+ };
+ const controller = new CausewayInteractionControllerElement();
+ controller.addEventListener('causeway-action-prompt-state-change', event => published.push(event.detail));
+ document.body.appendChild(controller);
+ assert.equal(await controller.beginAction('changePassword', context), true);
+ assert.match(controller.innerHTML, /type="password"/);
+
+ const input = new Event('input');
+ input.target = {
+ getAttribute: name => name === 'data-causeway-editor' ? 'password' : null,
+ value: 'top secret',
+ checked: false
+ };
+ controller.dispatchEvent(input);
+ await new Promise(resolve => setTimeout(resolve, 300));
+ assert.doesNotMatch(controller.innerHTML, /top secret/);
+ assert.match(controller.innerHTML, /Rejected \[protected\]/);
+ assert.equal(published.at(-1).values.password, null);
+ assert.doesNotMatch(JSON.stringify(published), /top secret/);
+ controller.cancelPrompt();
+});
+
test('action prompts recompute autocomplete suggestions and cancel without invoking', async () => {
let invoked = false;
const autoCompleteField = field('autoComplete', {kind: 'LIST', name: null, ofType: scalar('String')}, [argument('search', scalar('String'))]);
diff --git a/viewers/webcomponents/foundation/test/value-codecs.test.mjs b/viewers/webcomponents/foundation/test/value-codecs.test.mjs
new file mode 100644
index 00000000000..ce215a0e774
--- /dev/null
+++ b/viewers/webcomponents/foundation/test/value-codecs.test.mjs
@@ -0,0 +1,183 @@
+/*
+ * 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
+ *
+ * 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.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {installDomShim} from './dom-shim.mjs';
+
+installDomShim();
+const {
+ CausewayValueCodecError,
+ CausewayValueCodecRegistry,
+ defaultValueCodecRegistry,
+ parseCausewayValue,
+ renderCausewayEditor,
+ selectCausewayValueCodec
+} = await import('../src/index.mjs');
+
+const scalar = name => ({kind: 'SCALAR', name, ofType: null});
+const required = type => ({kind: 'NON_NULL', name: null, ofType: type});
+const context = (name, value = null, options = {}) => ({
+ name: name.toLowerCase(),
+ value,
+ choices: [],
+ suggestions: [],
+ enumValues: [],
+ inputType: options.required ? required(scalar(name)) : scalar(name),
+ required: options.required ?? false,
+ inputId: `${name}-input`,
+ labelId: `${name}-label`,
+ descriptionId: '',
+ errorId: '',
+ testId: ''
+});
+
+function parse(typeName, value, options = {}) {
+ const codecContext = {...context(typeName, null, options), value, checked: options.checked};
+ return parseCausewayValue(selectCausewayValueCodec(codecContext), codecContext);
+}
+
+test('exact numeric codecs preserve lexical precision scale boundaries and null', () => {
+ const decimal = renderCausewayEditor(context('BigDecimal', '9007199254740993.1200'));
+ assert.equal(decimal.editorId, 'exact-number');
+ assert.equal(decimal.codecId, 'exact-numeric');
+ assert.match(decimal.html, /type="text"/);
+ assert.match(decimal.html, /value="9007199254740993.1200"/);
+ assert.equal(decimal.editor.parse({value: '9007199254740993.1200'}), '9007199254740993.1200');
+
+ const advertisedString = renderCausewayEditor({
+ ...context('String', '9007199254740993.1200'),
+ semanticType: 'rich__java_math_BigDecimal'
+ });
+ assert.equal(advertisedString.editorId, 'exact-number');
+ assert.equal(advertisedString.editor.parse({value: '9007199254740993.1200'}), '9007199254740993.1200');
+
+ assert.equal(parse('BigInteger', '123456789012345678901234567890'), '123456789012345678901234567890');
+ assert.equal(parse('Long', '-9223372036854775808'), '-9223372036854775808');
+ assert.equal(parse('Long', '9223372036854775807'), '9223372036854775807');
+ assert.equal(parse('BigDecimal', '+1.2300e+40'), '+1.2300e+40');
+ assert.equal(parse('BigDecimal', ''), null);
+ assert.throws(() => parse('Long', '9223372036854775808'), CausewayValueCodecError);
+ assert.throws(() => parse('BigInteger', '1.5'), CausewayValueCodecError);
+ assert.throws(() => parse('BigDecimal', '1.2.3'), CausewayValueCodecError);
+ assert.throws(() => parse('BigDecimal', '', {required: true}), /required/);
+});
+
+test('machine numeric codecs accept only representable values', () => {
+ assert.equal(parse('Int', '2147483647'), 2147483647);
+ assert.equal(parse('Byte', '-128'), -128);
+ assert.equal(parse('Short', '32767'), 32767);
+ assert.equal(parse('Float', '1.25'), 1.25);
+ assert.throws(() => parse('Int', '2147483648'), /-2147483648 to 2147483647/);
+ assert.throws(() => parse('Byte', '128'), /-128 to 127/);
+ assert.throws(() => parse('Float', 'Infinity'), /finite/);
+});
+
+test('nullable Boolean remains distinct from false while required Boolean is two-state', () => {
+ const nullable = renderCausewayEditor(context('Boolean', null));
+ assert.equal(nullable.editorId, 'boolean');
+ assert.equal(nullable.codecId, 'boolean');
+ assert.match(nullable.html, /<select/);
+ assert.match(nullable.html, /value="" selected/);
+ assert.equal(nullable.editor.parse({value: ''}), null);
+ assert.equal(nullable.editor.parse({value: 'false'}), false);
+ assert.equal(nullable.editor.parse({value: 'true'}), true);
+
+ const twoState = renderCausewayEditor(context('Boolean', false, {required: true}));
+ assert.match(twoState.html, /type="checkbox"/);
+ assert.equal(twoState.editor.parse({checked: false}), false);
+ assert.equal(twoState.editor.parse({checked: true}), true);
+});
+
+test('temporal codecs preserve local fractional offset zoned and legacy lexical forms', () => {
+ assert.equal(parse('LocalDate', '2024-02-29'), '2024-02-29');
+ assert.equal(parse('LocalTime', '23:59:58.123456789'), '23:59:58.123456789');
+ assert.equal(parse('LocalDateTime', '2026-08-23T10:15:30.123456'), '2026-08-23T10:15:30.123456');
+ assert.equal(parse('OffsetTime', '10:15:30.123+05:30'), '10:15:30.123+05:30');
+ assert.equal(parse('OffsetDateTime', '2026-08-23T10:15:30-04:00'), '2026-08-23T10:15:30-04:00');
+ assert.equal(parse('DateTime', '2026-08-23T14:15:30Z'), '2026-08-23T14:15:30Z');
+ assert.equal(parse('LegacyDateTime', '2026-08-23T14:15:30Z'), '2026-08-23T14:15:30Z');
+ assert.equal(parse('ZonedDateTime', '2026-11-01T01:30:00-04:00[America/New_York]'),
+ '2026-11-01T01:30:00-04:00[America/New_York]');
+
+ const propertyContext = renderCausewayEditor({
+ ...context('String', '2026-11-01T01:30:00-04:00[America/New_York]'),
+ name: 'zonedProperty',
+ semanticType: 'rich__java_time_ZonedDateTime'
+ });
+ const actionContext = renderCausewayEditor({
+ ...context('Time', null),
+ name: 'offsetParameter',
+ semanticType: 'rich__java_time_OffsetTime'
+ });
+ assert.equal(propertyContext.editorId, 'temporal');
+ assert.equal(propertyContext.editor.parse({value: '2026-11-01T01:30:00-05:00[America/New_York]'}),
+ '2026-11-01T01:30:00-05:00[America/New_York]');
+ assert.equal(actionContext.editor.parse({value: ''}), null);
+ assert.equal(actionContext.editor.parse({value: '10:15:30.123456789+05:30'}),
+ '10:15:30.123456789+05:30');
+ assert.throws(() => propertyContext.editor.parse({value: '2026-11-01T01:30:00'}), /ZonedDateTime/);
+ assert.throws(() => parse('LocalDate', '2023-02-29'), /LocalDate/);
+ assert.throws(() => parse('OffsetDateTime', '2026-08-23T10:15:30'), /OffsetDateTime/);
+ assert.throws(() => parse('ZonedDateTime', '2026-08-23T10:15:30Z'), /ZonedDateTime/);
+});
+
+test('URL is constrained and custom scalar input fails closed without registration', () => {
+ assert.equal(parse('Url', 'https://causeway.apache.org/'), 'https://causeway.apache.org/');
+ assert.throws(() => parse('Url', 'javascript:alert(1)'), /HTTP or HTTPS/);
+
+ const protectedValue = renderCausewayEditor({
+ ...context('String', 'prior secret', {required: true}),
+ semanticType: 'rich__causeway_applib_value_Password'
+ });
+ assert.equal(protectedValue.codecId, 'protected');
+ assert.match(protectedValue.html, /type="password"/);
+ assert.match(protectedValue.html, /autocomplete="new-password"/);
+ assert.doesNotMatch(protectedValue.html, /prior secret/);
+ assert.equal(protectedValue.editor.parse({value: 'new secret'}), 'new secret');
+
+ for (const resourceType of ['Blob', 'Clob', 'LocalResourcePath']) {
+ const resource = renderCausewayEditor({
+ ...context('String', null),
+ semanticType: `rich__causeway_applib_value_${resourceType}`
+ });
+ assert.equal(resource.editorId, 'unsupported');
+ assert.throws(() => resource.editor.parse({value: 'private resource content'}),
+ /No reversible input codec/);
+ }
+
+ const unsupported = renderCausewayEditor(context('ApplicationMoney', '10 GBP'));
+ assert.equal(unsupported.editorId, 'unsupported');
+ assert.equal(unsupported.codecId, 'unsupported');
+ assert.match(unsupported.html, /Unsupported editor/);
+ assert.throws(() => unsupported.editor.parse({value: '11 GBP'}), /No reversible input codec/);
+
+ const registry = new CausewayValueCodecRegistry(defaultValueCodecRegistry.registrations);
+ registry.register({
+ id: 'money',
+ priority: 1000,
+ supports: candidate => candidate.inputType?.name === 'ApplicationMoney',
+ parse: candidate => ({amount: candidate.value, currency: 'GBP'}),
+ toControlValue: value => value?.amount ?? '',
+ toGraphQLValue: value => value
+ });
+ const codec = registry.select(context('ApplicationMoney'));
+ assert.deepEqual(parseCausewayValue(codec, {...context('ApplicationMoney'), value: '11.25'}),
+ {amount: '11.25', currency: 'GBP'});
+});
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 07416ddd59d..f2f08454684 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
@@ -355,7 +355,12 @@ void disposableOwnerExercisesCreateAndEveryObjectAction() {
objectAction("bookVisit").click();
waitForPrompt("bookVisit");
final var petReference = page.locator(parameter("pet"));
- petReference.waitFor();
+ try {
+ petReference.waitFor();
+ } catch (com.microsoft.playwright.TimeoutError cause) {
+ throw new AssertionError(String.valueOf(
+ page.locator(PROMPT).evaluate("element => element.outerHTML")), cause);
+ }
page.waitForFunction("selector => document.querySelector(selector)?.dataset.widgetState === 'ready'", parameter("pet"));
assertThat(petReference.evaluate("element => element.value?.id")).isNotNull();
assertThat(page.locator(parameter("visitAt")).count())