You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/10/08 14:59:40 UTC
(juneau) branch master updated: Add open card-type registry with datatables as a registered card, card class attribute, run-report module, and views hooks (visibleWhen, confirm pipeline, URL state, loud export, badges, inline edit, related lists); remove the datatables module; use Shorts in console-ui-freemarker; default fetchJson Accept to JSON; fix RunView a04 race
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 718ea8ce1d Add open card-type registry with datatables as a registered card, card class attribute, run-report module, and views hooks (visibleWhen, confirm pipeline, URL state, loud export, badges, inline edit, related lists); remove the datatables module; use Shorts in console-ui-freemarker; default fetchJson Accept to JSON; fix RunView a04 race
718ea8ce1d is described below
commit 718ea8ce1dca3966f3075b1d0d020e74a234a7c8
Author: James Bognar <ja...@apache.org>
AuthorDate: Thu Oct 8 10:59:33 2026 -0400
Add open card-type registry with datatables as a registered card, card class attribute, run-report module, and views hooks (visibleWhen, confirm pipeline, URL state, loud export, badges, inline edit, related lists); remove the datatables module; use Shorts in console-ui-freemarker; default fetchJson Accept to JSON; fix RunView a04 race
---
juneau-bom/pom.xml | 2 +-
.../ConsoleDataTablesFreemarkerMixin.java | 154 ----
.../console/datatables/DataTableMethodModel.java | 106 ---
.../console/datatables/package-info.java | 25 -
.../juneau/console/datatables/datatable.ftlh | 33 -
.../ConsoleDataTablesFreemarkerMixin_Test.java | 313 -------
.../test/resources/templates/admin/releases.ftlh | 22 -
.../freemarker/console/AssetUrlMethodModel.java | 4 +-
.../freemarker/console/CardDirectiveModel.java | 54 +-
.../server/view/freemarker/console/CardSpec.java | 13 +-
.../freemarker/console/ConsoleDirectiveModel.java | 24 +-
.../freemarker/console/ConsoleFreemarkerMixin.java | 40 +-
.../freemarker/console/ConsoleOutputCardType.java | 4 +-
.../console/ConsoleSlotDirectiveModel.java | 10 +-
.../console/ConsoleTemplateValidator.java | 70 +-
.../freemarker/console/DatatablesCardType.java | 72 +-
.../view/freemarker/console/FtlAttrLists.java | 12 +-
.../freemarker/console/HasToolkitMethodModel.java | 4 +-
.../freemarker/console/MainDirectiveModel.java | 6 +-
.../server/view/freemarker/console/NavNode.java | 10 +-
.../console/NavigationDirectiveModel.java | 4 +-
.../freemarker/console/NodeDirectiveModel.java | 8 +-
.../view/freemarker/console/PageCapture.java | 32 +-
.../freemarker/console/PageDirectiveModel.java | 4 +-
.../view/freemarker/console/TagMethodModel.java | 4 +-
.../freemarker/console/ThemeDirectiveModel.java | 8 +-
.../freemarker/console/TokenDirectiveModel.java | 6 +-
.../view/freemarker/console/ToolkitPack.java | 10 +-
.../freemarker/console/ToolkitPackRegistry.java | 11 +-
.../freemarker/console/CardDirective_Test.java | 31 +
.../ConsoleTemplateValidator_SelfLint_Test.java | 4 +-
.../console/ConsoleTemplateValidator_Test.java | 37 +-
.../console/DatatablesCardType_Test.java | 25 +-
.../console/ModuleGraph_ImportScan_Test.java | 4 +-
.../freemarker/console/PageDirective_Test.java | 2 +-
.../console/ViewContractVersion_Test.java | 94 +--
.../templates/admin/page-cards-class-bad.ftlh} | 9 +-
.../templates/admin/page-cards-class-digit.ftlh} | 5 +-
.../cv-omit.ftlh => admin/page-cards-class.ftlh} | 18 +-
.../test/resources/templates/c1/cv-pinned-ok.ftlh | 29 -
.../rest/server/console/ConsoleChromeMixin.java | 34 +
.../resources/org/apache/juneau/console/chrome.css | 62 ++
.../org/apache/juneau/console/juneau-badges.js | 396 +++++++++
.../org/apache/juneau/console/juneau-console.js | 45 +-
.../apache/juneau/console/juneau-page.schema.json | 92 ++-
.../console/ConsoleChromeMixin_Serving_Test.java | 11 +
.../rest/server/console/ModuleGraph_Test.java | 13 +-
.../server/console/PageContractSchema_Test.java | 5 +-
.../contracts/invalid-badge-refreshms.json | 1 +
.../contracts/invalid-badge-unknown-key.json | 1 +
...tmeta.json => invalid-datatables-slotmeta.json} | 0
.../src/test/resources/contracts/valid-badge.json | 1 +
.../DataTablesTable_HtmlRenderHonoring_Test.java | 1 -
juneau-rest/juneau-rest-server-views/pom.xml | 8 +
.../apache/juneau/rest/server/views/BadgeDef.java | 354 ++++++++
.../rest/server/views/ConsoleOutputChecks.java | 16 +-
.../rest/server/views/FileRunViewSource.java | 1 +
.../apache/juneau/rest/server/views/RegionDef.java | 55 +-
.../apache/juneau/rest/server/views/RowAction.java | 126 ++-
.../rest/server/views/RowActionEnabledRule.java | 2 +
.../juneau/rest/server/views/RunViewDef.java | 1 +
.../juneau/rest/server/views/RunViewEndpoints.java | 1 +
.../juneau/rest/server/views/RunViewLog.java | 1 +
.../juneau/rest/server/views/RunViewPage.java | 1 +
.../juneau/rest/server/views/ViewsMixin.java | 23 +-
.../juneau/rest/server/views/VisibilityRule.java | 207 +++++
.../rest/server/views/report/package-info.java | 32 -
.../org/apache/juneau/views/juneau-helpers.js | 2 +
.../org/apache/juneau/views/juneau-regions.js | 35 +-
.../org/apache/juneau/views/juneau-renders.js | 18 +
.../org/apache/juneau/views/juneau-ribbon.js | 98 ++-
.../org/apache/juneau/views/juneau-run-view.js | 106 ++-
.../org/apache/juneau/views/juneau-urlstate.js | 180 ++++-
.../org/apache/juneau/views/juneau-views.css | 78 ++
.../org/apache/juneau/views/juneau-views.js | 896 +++++++++++++++++++--
.../juneau/rest/server/views/BadgeDef_Test.java | 126 +++
.../server/views/ConfirmRenderer_BrowserTest.java | 126 +++
.../rest/server/views/ConsoleBadges_Test.java | 121 +++
.../rest/server/views/ConsoleCards_Shell_Test.java | 4 +-
.../rest/server/views/ConsoleVisibility_Test.java | 63 ++
.../server/views/DialogWizard_BrowserTest.java | 124 +++
.../rest/server/views/ExportLoud_BrowserTest.java | 128 +++
.../rest/server/views/FileRunViewSource_Test.java | 1 +
.../juneau/rest/server/views/NodeHarness.java | 133 +++
.../server/views/PendingBadge_BrowserTest.java | 129 +++
.../rest/server/views/RelatedList_BrowserTest.java | 324 ++++++++
.../rest/server/views/RowAction_Tone_Test.java | 67 ++
.../rest/server/views/RunViewChecks_Test.java | 1 +
.../juneau/rest/server/views/RunViewLog_Test.java | 16 +-
.../rest/server/views/RunViewMixin_Test.java | 3 +-
.../juneau/rest/server/views/RunViewPage_Test.java | 3 +-
.../rest/server/views/RunView_BrowserTest.java | 3 +-
.../views/UrlStateDirectives_BrowserTest.java | 126 +++
.../rest/server/views/ViewsJs_Confirm_Test.java | 107 +++
.../server/views/ViewsJs_DialogHandle_Test.java | 124 +++
.../server/views/ViewsJs_ExportDetect_Test.java | 83 ++
.../rest/server/views/ViewsJs_Hooks_Test.java | 126 +++
.../rest/server/views/ViewsJs_InlineEdit_Test.java | 122 +++
.../server/views/ViewsJs_RowActionsRun_Test.java | 92 +++
.../rest/server/views/ViewsJs_Rules_Test.java | 76 ++
.../rest/server/views/ViewsJs_RunView_Test.java | 10 +-
.../views/ViewsJs_UrlStateDirectives_Test.java | 129 +++
.../views/ViewsJs_VisibilityWiring_Test.java | 92 +++
.../server/views/VisibilityRule_Corpus_Test.java | 70 ++
.../rest/server/views/VisibilityRule_Test.java | 123 +++
.../src/test/js/badges.cjs | 250 ++++++
.../src/test/js/badges.html.cjs | 93 +++
.../src/test/js/confirm-renderer.html.cjs | 139 ++++
.../src/test/js/confirm.cjs | 90 +++
.../src/test/js/console-visibility.cjs | 93 +++
.../src/test/js/dialog-handle.cjs | 163 ++++
.../src/test/js/dialog-wizard.html.cjs | 107 +++
.../src/test/js/edit-renderer.cjs | 203 +++++
.../src/test/js/export-detect.cjs | 94 +++
.../src/test/js/export-loud.html.cjs | 81 ++
.../src/test/js/hooks-api.cjs | 104 +++
.../src/test/js/related-list.cjs | 171 ++++
.../src/test/js/ribbon-normalize.cjs | 2 +-
.../src/test/js/row-actions-run.cjs | 94 +++
.../juneau-rest-server-views/src/test/js/rules.cjs | 79 ++
.../src/test/js/run-view-browser.cjs | 7 +
.../src/test/js/run-view.cjs | 69 ++
.../src/test/js/url-state-directives.html.cjs | 99 +++
.../src/test/js/urlstate-directives.cjs | 151 ++++
.../src/test/js/visibility-corpus.json | 26 +
.../src/test/js/visibility-wiring.cjs | 134 +++
.../src/test/resources/card-lift-corpus.json | 18 -
.../juneau/rest/server/widgets/ActionRef.java | 7 +-
.../org/apache/juneau/rest/server/widgets/Op.java | 30 +-
.../pom.xml | 20 +-
.../rest/server/runreport}/JUnitXmlParser.java | 8 +-
.../server/runreport}/JUnitXmlReportReader.java | 3 +-
.../server/runreport}/JestJsonReportReader.java | 10 +-
.../runreport}/PlaywrightJsonReportReader.java | 9 +-
.../rest/server/runreport}/ReportBuilder.java | 2 +-
.../juneau/rest/server/runreport}/ReportJson.java | 5 +-
.../rest/server/runreport}/ReportLimits.java | 2 +-
.../rest/server/runreport}/ReportReader.java | 2 +-
.../rest/server/runreport}/ReportReaders.java | 2 +-
.../rest/server/runreport}/ReportResult.java | 5 +-
.../juneau/rest/server/runreport}/ReportTest.java | 4 +-
.../juneau/rest/server/runreport}/ReportText.java | 2 +-
.../juneau/rest/server/runreport}/RunEvent.java | 12 +-
.../rest/server/runreport}/RunViewChecks.java | 64 +-
.../server/runreport}/SurefireReportReader.java | 4 +-
.../juneau/rest/server/runreport/package-info.java | 38 +
.../runreport}/JUnitXmlReportReader_Test.java | 4 +-
.../runreport}/JestJsonReportReader_Test.java | 4 +-
.../PlaywrightJsonReportReader_Test.java | 4 +-
.../rest/server/runreport}/ReportReaders_Test.java | 14 +-
.../rest/server/runreport}/ReportResult_Test.java | 6 +-
.../rest/server/runreport}/ReportTestSupport.java | 4 +-
.../rest/server/runreport}/ReportText_Test.java | 2 +-
.../rest/server/runreport}/RunEvent_Test.java | 4 +-
.../runreport}/SurefireReportReader_Test.java | 4 +-
.../rest/server/runreport}/jest-crashed.json | 0
.../juneau/rest/server/runreport}/jest-mixed.json | 0
.../rest/server/runreport}/jest-truncated.json | 0
.../juneau/rest/server/runreport}/junit-dtd.xml | 0
.../rest/server/runreport}/junit-truncated.xml | 0
.../rest/server/runreport}/playwright-mixed.json | 0
.../server/runreport}/playwright-truncated.json | 0
.../juneau/rest/server/runreport}/pytest.xml | 0
.../surefire-mixed/TEST-com.example.BetaTest.xml | 0
.../surefire-ok/TEST-com.example.AlphaTest.xml | 0
juneau-rest/pom.xml | 2 +-
166 files changed, 8072 insertions(+), 1350 deletions(-)
diff --git a/juneau-bom/pom.xml b/juneau-bom/pom.xml
index 35bd56f906..af134dc4bc 100644
--- a/juneau-bom/pom.xml
+++ b/juneau-bom/pom.xml
@@ -65,11 +65,11 @@
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-management-logging</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-datatables</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-widgets</artifactId><version>${project.version}</version></dependency>
+ <dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-run-report</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-views</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-views-markdown</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui-freemarker</artifactId><version>${project.version}</version></dependency>
- <dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui-freemarker-datatables</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-reactive</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-reactive-reactor</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-view-jsp</artifactId><version>${project.version}</version></dependency>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin.java b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin.java
deleted file mode 100644
index 87e48bde5d..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin.java
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * 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.juneau.rest.server.view.freemarker.console.datatables;
-
-import org.apache.juneau.rest.server.*;
-import org.apache.juneau.rest.server.view.freemarker.*;
-import org.apache.juneau.rest.server.view.freemarker.console.*;
-
-import freemarker.template.*;
-
-/**
- * A {@link ConsoleFreemarkerMixin} that additionally makes the reserved-classpath {@code <@datatable>} macro
- * template ({@code org/apache/juneau/console/datatables/datatable.ftlh}) resolvable from any consumer template.
- *
- * <h5 class='section'>Used INSTEAD OF {@link ConsoleFreemarkerMixin}/{@link org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin},
- * not beside it:</h5>
- * <p>
- * Same load-bearing-return-type rule as {@link ConsoleFreemarkerMixin}'s own class javadoc: register it typed as
- * {@code FreemarkerMixin} so {@code FreemarkerViewRenderer}'s exact-type {@code getBean(FreemarkerMixin.class)}
- * lookup finds it.
- *
- * <p class='bjava'>
- * <ja>@Bean</ja> <jk>public</jk> FreemarkerMixin freemarker() {
- * <jk>return</jk> ConsoleDataTablesFreemarkerMixin.<jsm>create</jsm>().basePath(<js>"/templates/"</js>).build();
- * }
- * </p>
- *
- * <h5 class='section'>No second {@code ClassTemplateLoader} needed:</h5>
- * <p>
- * {@link ConsoleFreemarkerMixin#resolveConfiguration} already splices in a classpath-<b>root</b>-rooted
- * {@code ClassTemplateLoader(getClass().getClassLoader(), "")}. Because {@code getClass()} resolves to <b>this</b>
- * subclass's runtime class, its classloader sees every module's classpath resources (this module's
- * {@code datatable.ftlh} included) — so the reserved {@link #DATATABLE_TEMPLATE_PATH} resolves through that
- * same loader without any additional wiring here.
- *
- * <h5 class='section'>Consumer-supplied {@code Configuration} is the same instance, then fill-missing stamped:</h5>
- * <p>
- * Same rule as {@link ConsoleFreemarkerMixin}: {@link #resolveConfiguration} returns the consumer bean
- * {@code ==}-identical so settings win, then fill-missing-stamps {@code jcDataTableHtml} (and the parent
- * reserved names) and splices the console loader only when {@link ConsoleFreemarkerMixin#BASE_TEMPLATE_PATH}
- * would not already resolve. Opt out by registering {@link org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin}
- * instead of this class.
- *
- * @since 10.0.0
- */
-public class ConsoleDataTablesFreemarkerMixin extends ConsoleFreemarkerMixin {
-
- static {
- FreemarkerMixin.registerSubtype(ConsoleDataTablesFreemarkerMixin.class);
- }
-
- /** The reserved classpath-root-relative location of the shipped {@code <@datatable>} macro template. */
- public static final String DATATABLE_TEMPLATE_PATH = "org/apache/juneau/console/datatables/datatable.ftlh";
-
- /** No-arg constructor — mirrors {@link ConsoleFreemarkerMixin#ConsoleFreemarkerMixin()}. */
- public ConsoleDataTablesFreemarkerMixin() {
- this(create());
- }
-
- /**
- * Builder constructor.
- *
- * @param builder The builder. Must not be {@code null}.
- */
- protected ConsoleDataTablesFreemarkerMixin(Builder builder) {
- super(builder);
- }
-
- /**
- * Creates a new builder.
- *
- * @return A new builder.
- */
- @SuppressWarnings({
- "java:S9149" // Intentional per-subclass builder-factory override matching FreemarkerMixin.create()'s own convention; each mixin subclass returns its own nested Builder type.
- })
- public static Builder create() {
- return new Builder();
- }
-
- // Same wrap-once identity-cache pattern as ConsoleFreemarkerMixin's own field, kept as a SEPARATE field (not
- // shared with the parent's private one) since this class needs to answer "have I already fill-missing-stamped
- // jcDataTableHtml on THIS config" independently of the parent's loader / reserved-name stamp. Presence
- // checks still run on a cache miss so two mixin instances sharing one Spring singleton do not re-set.
- @SuppressWarnings({
- "java:S3077" // volatile required for correct double-checked-locking safe-publication; see ConsoleFreemarkerMixin's identical field.
- })
- private volatile Configuration wrappedConfiguration;
-
- /**
- * Resolves the active {@link Configuration}: delegates to {@link ConsoleFreemarkerMixin#resolveConfiguration}
- * for console-loader / reserved-name stamping, then fill-missing-registers {@code jcDataTableHtml}.
- *
- * <p>
- * A consumer {@code Configuration} bean is the same {@code ==} instance (settings win). {@code jcDataTableHtml}
- * is set only when unset. Identity wrap-once plus the parent's loader presence check keep a shared Spring
- * singleton from nesting {@code MultiTemplateLoader}.
- *
- * @param req The current REST request.
- * @return The active FreeMarker configuration. Never {@code null}.
- */
- @Override
- public Configuration resolveConfiguration(RestRequest req) {
- var cfg = super.resolveConfiguration(req);
- if (cfg == wrappedConfiguration)
- return cfg;
- synchronized (this) {
- if (cfg != wrappedConfiguration) {
- if (cfg.getSharedVariable(DataTableMethodModel.NAME) == null)
- cfg.setSharedVariable(DataTableMethodModel.NAME, new DataTableMethodModel());
- wrappedConfiguration = cfg;
- }
- }
- return cfg;
- }
-
- /**
- * Builder for {@link ConsoleDataTablesFreemarkerMixin}.
- */
- public static class Builder extends ConsoleFreemarkerMixin.Builder {
-
- /** Constructor — package access for {@link ConsoleDataTablesFreemarkerMixin#create()}. */
- protected Builder() {}
-
- /**
- * Builds the {@link ConsoleDataTablesFreemarkerMixin}.
- *
- * <p>
- * Overrides {@link org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin.Builder#build()}
- * — without this override, the inherited method would silently return a plain
- * {@link ConsoleFreemarkerMixin} with no {@code <@datatable>} macro.
- *
- * @return A new {@link ConsoleDataTablesFreemarkerMixin} instance.
- */
- @Override
- public ConsoleDataTablesFreemarkerMixin build() {
- return new ConsoleDataTablesFreemarkerMixin(this);
- }
- }
-}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/DataTableMethodModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/DataTableMethodModel.java
deleted file mode 100644
index e609f4f2c3..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/DataTableMethodModel.java
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * 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.juneau.rest.server.view.freemarker.console.datatables;
-
-import java.util.*;
-
-import org.apache.juneau.bean.html5.*;
-import org.apache.juneau.marshall.html.*;
-import org.apache.juneau.rest.server.datatables.*;
-import org.apache.juneau.rest.server.view.freemarker.console.*;
-
-import freemarker.core.*;
-import freemarker.template.*;
-import freemarker.template.utility.*;
-
-/**
- * The FreeMarker shared-variable method backing the {@code <@datatable id=... rows=... rowType=.../>} macro defined
- * in the reserved {@code datatable.ftlh} template.
- *
- * <p>
- * Reuses the exact serialize-then-mark-trusted adapter {@code console-ui-freemarker}'s {@code TagMethodModel} built
- * for {@code <@tag>} (ticket 361 Phase 5) — not reinvented here: (1) call {@link DataTablesTable#of(String,
- * Collection, Class)} (which additively honors each row property's {@code @Html(render=...)} as of Phase 6), (2) add
- * {@code class="jc-table"} alongside the {@link DataTablesTable#MARKER_ATTR} marker the shipped
- * {@code juneau-datatables.js} glue already looks for, (3) serialize the returned {@link Table} via its own
- * {@code toString()} ({@code HtmlSerializer.DEFAULT_SIMPLE_SQ}), (4) wrap the resulting markup string via
- * {@link HTMLOutputFormat#fromMarkup(String) HTMLOutputFormat.INSTANCE.fromMarkup(...)} so a {@code .ftlh}
- * template's default {@code ${...}} auto-escaping does not double-escape it.
- *
- * <p>
- * Registered as a {@link freemarker.template.Configuration#setSharedVariable(String, TemplateModel) shared variable}
- * by {@link ConsoleDataTablesFreemarkerMixin#resolveConfiguration}, visible to {@code datatable.ftlh} and,
- * transitively (once included), to the consumer's own template.
- *
- * <h5 class='section'>Argument coercion:</h5>
- * <p>
- * {@code id} and {@code rowType} accept a plain FTL string literal. {@code rows} accepts an FTL sequence of beans
- * (or {@code Map}s) — e.g. a Java {@code List} bound as a template attribute — unwrapped recursively via
- * {@link DeepUnwrap#unwrap(TemplateModel)}: each element unwraps to its underlying Java bean/{@code Map}, not a
- * FreeMarker bean-model wrapper, exactly as {@code DataTablesTable.of(...)} needs.
- *
- * @since 10.0.0
- */
-final class DataTableMethodModel implements TemplateMethodModelEx {
-
- /** The shared-variable name {@code datatable.ftlh}'s {@code <#macro datatable ...>} calls. */
- static final String NAME = "jcDataTableHtml";
-
- @Override
- public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException {
- if (arguments.size() != 3)
- throw new TemplateModelException(NAME + "(id, rows, rowType) requires exactly 3 arguments, got " + arguments.size() + ".");
- var id = asPlainString(arguments.get(0));
- var rows = asRows(arguments.get(1));
- var rowTypeName = asPlainString(arguments.get(2));
- var rowType = resolveRowType(rowTypeName);
- Table table;
- try {
- table = DataTablesTable.of(id, rows, rowType);
- } catch (RuntimeException ex) {
- throw new TemplateModelException(ex.getMessage(), ex);
- }
- table.class_("jc-table");
- // A server-mode table outside a <@card> still needs the glue; record it on the enclosing <@page>.
- var cap = PageCapture.get(Environment.getCurrentEnvironment());
- if (cap != null && cap.inPage())
- cap.require(List.of(ToolkitPackRegistry.PACK_DATATABLES_GLUE));
- var markup = HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(table);
- return HTMLOutputFormat.INSTANCE.fromMarkup(markup);
- }
-
- private static Class<?> resolveRowType(String rowTypeName) throws TemplateModelException {
- try {
- return Class.forName(rowTypeName);
- } catch (ClassNotFoundException ex) {
- throw new TemplateModelException("Unknown rowType class '" + rowTypeName + "'.", ex);
- }
- }
-
- private static String asPlainString(Object arg) throws TemplateModelException {
- var raw = DeepUnwrap.unwrap((TemplateModel) arg);
- return String.valueOf(raw);
- }
-
- private static Collection<?> asRows(Object arg) throws TemplateModelException {
- var raw = DeepUnwrap.unwrap((TemplateModel) arg);
- if (raw instanceof Collection<?> c)
- return c;
- throw new TemplateModelException(NAME + "(id, rows, rowType): 'rows' must unwrap to a Collection, got "
- + (raw == null ? "null" : raw.getClass().getName()) + ".");
- }
-}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/package-info.java b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/package-info.java
deleted file mode 100644
index 91bc8a26ee..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/package-info.java
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * 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.
- */
-
-/**
- * The {@code <@datatable>} FreeMarker macro — the one module allowed to depend on both the FreeMarker chrome
- * add-on ({@code juneau-rest-server-console-ui-freemarker}) and {@code juneau-rest-server-datatables}, so a
- * chrome-only consumer never sees {@code -datatables} on the classpath.
- *
- * @since 10.0.0
- */
-package org.apache.juneau.rest.server.view.freemarker.console.datatables;
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/resources/org/apache/juneau/console/datatables/datatable.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/resources/org/apache/juneau/console/datatables/datatable.ftlh
deleted file mode 100644
index 4078f8012c..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/resources/org/apache/juneau/console/datatables/datatable.ftlh
+++ /dev/null
@@ -1,33 +0,0 @@
-<#--
- 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.
--->
-<#--
- Reserved <@datatable> macro template (org/apache/juneau/console/datatables/datatable.ftlh).
-
- Shipped on the classpath ROOT by juneau-rest-server-console-ui-freemarker-datatables and made resolvable by
- ConsoleDataTablesFreemarkerMixin's augmented template loader (the SAME classpath-root ClassTemplateLoader
- ConsoleFreemarkerMixin already splices in for base.ftlh -- see that class's javadoc). Consumer templates pull it in
- with a loader-root-absolute include:
-
- <#include "/org/apache/juneau/console/datatables/datatable.ftlh">
-
- v1 scope: this file's only job is to define the <@datatable id=... rows=... rowType=.../> macro, which renders a
- DataTablesTable.of(...)-built <table class="jc-table" data-juneau-datatable ...> (additively honoring each row
- property's @Html(render=...), per TODO-361 Phase 6) through the same serialize-then-mark-trusted adapter <@tag>
- uses. rowType is the row bean's fully-qualified class name as a plain string (e.g. rowType="com.example.Release"),
- not a FTL type reference -- FreeMarker templates have no way to express a Java Class literal directly.
--->
-<#macro datatable id rows rowType>${jcDataTableHtml(id, rows, rowType)}</#macro>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin_Test.java
deleted file mode 100644
index 8efe9ede5f..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin_Test.java
+++ /dev/null
@@ -1,313 +0,0 @@
-/*
- * 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.juneau.rest.server.view.freemarker.console.datatables;
-
-import static org.apache.juneau.commons.utils.Shorts.*;
-import static org.junit.jupiter.api.Assertions.*;
-
-import java.util.*;
-import java.util.regex.*;
-
-import org.apache.juneau.*;
-import org.apache.juneau.bean.html5.Span;
-import org.apache.juneau.commons.inject.*;
-import org.apache.juneau.marshall.html.*;
-import org.apache.juneau.marshall.serializer.*;
-import org.apache.juneau.rest.mock.classic.*;
-import org.apache.juneau.rest.server.*;
-import org.apache.juneau.rest.server.servlet.*;
-import org.apache.juneau.rest.server.view.*;
-import org.apache.juneau.rest.server.view.freemarker.*;
-import org.apache.juneau.rest.server.view.freemarker.console.*;
-import org.apache.juneau.rest.server.views.*;
-import org.junit.jupiter.api.*;
-
-import freemarker.cache.*;
-import freemarker.template.Configuration;
-import freemarker.template.SimpleScalar;
-import freemarker.template.TemplateModel;
-
-/**
- * Ticket 361 Phase 7 gate: the {@code <@datatable>} macro (this module's only deliverable) — a golden-HTML
- * integration proof that a row bean's {@code @Html(render=...)} enum property renders as
- * pill markup (Phase 6's now-render-aware {@code DataTablesTable}) nested inside a {@code jc-table} through the
- * same trusted-HTML adapter Phase 5 built for {@code <@tag>}.
- */
-@SuppressWarnings({
- "resource" // Test-fixture AutoCloseables are managed by the test lifecycle, not real leaks (mixed-module resource analysis on test code).
-})
-class ConsoleDataTablesFreemarkerMixin_Test extends TestBase {
-
- public enum Release { RELEASED, DRAFT }
-
- /** Local stand-in for the retired {@code TagHtmlRender}: a status pill span. */
- public static class StatusPillRender extends HtmlRender<Enum<?>> {
- @Override
- public Object getContent(SerializerSession session, Enum<?> value) {
- if (value == null)
- return null;
- return new Span().class_("tag status " + value.name().toLowerCase(Locale.ROOT));
- }
- }
-
- /** Row bean: one plain property, one {@code @Html(render=...)}-annotated enum property. */
- public static class ReleaseRow {
- public String name = "widget";
- @Html(render=StatusPillRender.class) public Release status = Release.RELEASED;
- }
-
- //-----------------------------------------------------------------------------------------------------------------
- // RED control: a host wired with plain ConsoleFreemarkerMixin (the datatable macro's backing shared variable is
- // never registered) -- the reserved datatable.ftlh template itself DOES resolve (same classpath-root loader
- // ConsoleFreemarkerMixin already splices in sees every module's resources), but calling <@datatable> inside it
- // fails because jcDataTableHtml is undefined. Proves the gap is real: neither the macro's OWN registration nor
- // the Phase-6-updated DataTablesTable overload it depends on are wired without ConsoleDataTablesFreemarkerMixin.
- //-----------------------------------------------------------------------------------------------------------------
-
- @Rest(mixins=FreemarkerMixin.class)
- public static class PlainControlHost extends BasicRestServlet {
- private static final long serialVersionUID = 1L;
- @Bean public FreemarkerMixin freemarker() {
- return ConsoleFreemarkerMixin.create().basePath("/templates/").build();
- }
- @RestGet(path="/releases")
- public View releases() {
- return FreemarkerView.of("admin/releases.ftlh")
- .attr("releases", List.of(new ReleaseRow()))
- .attr("rowTypeName", ReleaseRow.class.getName());
- }
- }
-
- @Test void a01_plainConsoleFreemarkerMixin_datatableMacroUnresolvable() throws Exception {
- var c = MockRestClient.buildLax(PlainControlHost.class);
- c.get("/releases").run().assertStatus(500);
- }
-
- //-----------------------------------------------------------------------------------------------------------------
- // GREEN: ConsoleDataTablesFreemarkerMixin registers the <@datatable> macro's backing shared variable (on top of
- // everything ConsoleFreemarkerMixin already wires) -- the rendered output must contain the pill markup nested
- // inside a <td> inside a <table class="jc-table" data-juneau-datatable ...>.
- //-----------------------------------------------------------------------------------------------------------------
-
- @Rest(mixins={FreemarkerMixin.class, ViewsMixin.class})
- public static class DataTablesHost extends BasicRestServlet {
- private static final long serialVersionUID = 1L;
- @Bean public FreemarkerMixin freemarker() {
- return ConsoleDataTablesFreemarkerMixin.create().basePath("/templates/").chromeTemplate("admin/chrome.ftlh").build();
- }
- @RestGet(path="/page-releases")
- public View pageReleases() {
- return FreemarkerView.of("admin/page-datatable.ftlh")
- .attr("releases", List.of(new ReleaseRow()))
- .attr("rowTypeName", ReleaseRow.class.getName());
- }
- @RestGet(path="/releases")
- public View releases() {
- return FreemarkerView.of("admin/releases.ftlh")
- .attr("releases", List.of(new ReleaseRow()))
- .attr("rowTypeName", ReleaseRow.class.getName());
- }
- }
-
- @Test void a02_consoleDataTablesFreemarkerMixin_rendersPillMarkupInsideJcTable() throws Exception {
- var c = MockRestClient.buildLax(DataTablesHost.class);
- var body = c.get("/releases").run().assertStatus(200).getContent().asString();
- // find() on an anchor-free pattern (no wrapping .*) avoids the super-linear backtracking risk of
- // String.matches() with unbounded quantifiers at both ends.
- assertTrue(Pattern.compile("<table(?=[^>]*class=['\"]jc-table['\"])(?=[^>]*data-juneau-datatable)[^>]*>").matcher(body).find(),
- () -> "expected <table class='jc-table' ...data-juneau-datatable...> (attribute order not asserted), body:\n" + body);
- assertTrue(Pattern.compile("<td[^>]*>\\s*<span(?=[^>]*class=['\"]tag status released['\"])[^>]*>.*?</td>", Pattern.DOTALL).matcher(body).find(),
- () -> "expected <span class='tag status released'> nested inside a <td>, body:\n" + body);
- assertTrue(body.contains("widget"), () -> "expected the plain property's raw value too, body:\n" + body);
- assertFalse(body.contains("<span"), () -> "macro output was HTML-escaped (double-escaped), body:\n" + body);
- assertFalse(body.contains("jquery"), () -> "no <@page>, nothing recorded, body:\n" + body);
- }
-
- @Test void a02b_datatableInsidePage_recordsTheGlue() throws Exception {
- var c = MockRestClient.buildLax(DataTablesHost.class);
- var body = c.get("/page-releases").run().assertStatus(200).getContent().asString();
- var jq = body.indexOf("/jquery.min.js");
- var dt = body.indexOf("/js/dataTables.min.js");
- var glue = body.indexOf("juneau-datatables.js");
- var views = body.indexOf("juneau-views.js");
- assertTrue(jq >= 0 && jq < dt && dt < glue && glue < views, () -> body);
- }
-
- //-----------------------------------------------------------------------------------------------------------------
- // Module-graph re-check (S6): this module's main source is the ONLY one of the three new modules whose imports
- // include org.apache.juneau.rest.server.datatables.* -- console-ui and console-ui-freemarker main sources
- // still don't (re-verified here alongside the real code, not just at the POM level).
- //-----------------------------------------------------------------------------------------------------------------
-
- @Test void a03_onlyThisModuleImportsDatatablesPackage() throws Exception {
- var restDir = new java.io.File(System.getProperty("user.dir")).getParentFile();
-
- var thisModuleImportsDatatables = importsDatatablesPackage(new java.io.File(restDir, "juneau-rest-server-console-ui-freemarker-datatables/src/main/java"));
- assertTrue(thisModuleImportsDatatables, "console-ui-freemarker-datatables MUST import org.apache.juneau.rest.server.datatables.* (it's the only module allowed to)");
-
- assertFalse(importsDatatablesPackage(new java.io.File(restDir, "juneau-rest-server-console-ui/src/main/java")),
- "console-ui main source must NOT import org.apache.juneau.rest.server.datatables.*");
- assertFalse(importsDatatablesPackage(new java.io.File(restDir, "juneau-rest-server-console-ui-freemarker/src/main/java")),
- "console-ui-freemarker main source must NOT import org.apache.juneau.rest.server.datatables.*");
- }
-
- //-----------------------------------------------------------------------------------------------------------------
- // f) Consumer Configuration is the same instance, then fill-missing stamped
- //-----------------------------------------------------------------------------------------------------------------
-
- @Rest(mixins=FreemarkerMixin.class)
- public static class ConsumerConfigHost extends BasicRestServlet {
- private static final long serialVersionUID = 1L;
- static final Configuration USER_CFG = new Configuration(Configuration.VERSION_2_3_34);
- static {
- USER_CFG.setTemplateLoader(new StringTemplateLoader());
- }
- static final Object ORIGINAL_WRAPPER = USER_CFG.getObjectWrapper();
- static final String ORIGINAL_ENCODING = USER_CFG.getDefaultEncoding();
- static final Object ORIGINAL_OUTPUT_FORMAT = USER_CFG.getOutputFormat();
- static final long ORIGINAL_UPDATE_DELAY = USER_CFG.getTemplateUpdateDelayMilliseconds();
- @Bean public Configuration configuration() { return USER_CFG; }
- @Bean public FreemarkerMixin freemarker() { return ConsoleDataTablesFreemarkerMixin.create().build(); }
- @RestGet(path="/probe")
- public String probe(RestRequest req) {
- var mixin = (ConsoleDataTablesFreemarkerMixin) req.getContext().getBeanStore().getBean(FreemarkerMixin.class).orElseThrow();
- var cfg1 = mixin.resolveConfiguration(req);
- var cfg2 = mixin.resolveConfiguration(req);
- var sameInstance = cfg1 == USER_CFG && cfg2 == USER_CFG;
- var settingsUnchanged = cfg1.getObjectWrapper() == ORIGINAL_WRAPPER
- && eq(cfg1.getDefaultEncoding(), ORIGINAL_ENCODING)
- && cfg1.getOutputFormat() == ORIGINAL_OUTPUT_FORMAT
- && cfg1.getTemplateUpdateDelayMilliseconds() == ORIGINAL_UPDATE_DELAY;
- var varsFilled = cfg1.getSharedVariable(PageDirectiveModel.NAME) != null
- && cfg1.getSharedVariable(CardDirectiveModel.NAME) != null
- && cfg1.getSharedVariable(NavigationDirectiveModel.NAME) != null
- && cfg1.getSharedVariable(NodeDirectiveModel.NAME) != null
- && cfg1.getSharedVariable(ThemeDirectiveModel.NAME) != null
- && cfg1.getSharedVariable("jcTagHtml") != null
- && cfg1.getSharedVariable(DataTableMethodModel.NAME) != null;
- var reservedResolves = true;
- try {
- cfg1.getTemplate(ConsoleFreemarkerMixin.BASE_TEMPLATE_PATH);
- } catch (@SuppressWarnings("unused") Exception ex) {
- reservedResolves = false;
- }
- return sameInstance + ":" + settingsUnchanged + ":" + varsFilled + ":" + reservedResolves
- + ":" + loaderCount(cfg1) + ":" + loaderCount(cfg2);
- }
- }
-
- /**
- * Same assertions as {@code ConsoleFreemarkerMixin_Test.f01}, plus {@code jcDataTableHtml} is set.
- */
- @Test void f01_consumerSuppliedConfiguration_isAugmentedInPlace() throws Exception {
- var c = MockRestClient.buildLax(ConsumerConfigHost.class);
- var body = c.get("/probe").accept("text/plain").run().getContent().asString();
- assertEquals("true:true:true:true:2:2", body,
- () -> "expected same-instance, settings-unchanged, vars-filled (incl jcDataTableHtml), reserved-resolves, loader-count 2 then 2; got " + body);
- }
-
- @Rest(mixins=FreemarkerMixin.class)
- public static class CollisionConfigHost extends BasicRestServlet {
- private static final long serialVersionUID = 1L;
- static final Configuration COLLISION_CFG = new Configuration(Configuration.VERSION_2_3_34);
- static final TemplateModel PAGE_SENTINEL = new SimpleScalar("consumer-page");
- static {
- COLLISION_CFG.setSharedVariable(PageDirectiveModel.NAME, PAGE_SENTINEL);
- }
- @Bean public Configuration configuration() { return COLLISION_CFG; }
- @Bean public FreemarkerMixin freemarker() { return ConsoleDataTablesFreemarkerMixin.create().build(); }
- @RestGet(path="/probe")
- public String probe(RestRequest req) {
- var mixin = (ConsoleDataTablesFreemarkerMixin) req.getContext().getBeanStore().getBean(FreemarkerMixin.class).orElseThrow();
- var cfg = mixin.resolveConfiguration(req);
- var pageKept = cfg.getSharedVariable(PageDirectiveModel.NAME) == PAGE_SENTINEL;
- var othersFilled = cfg.getSharedVariable(CardDirectiveModel.NAME) != null
- && cfg.getSharedVariable(NavigationDirectiveModel.NAME) != null
- && cfg.getSharedVariable(NodeDirectiveModel.NAME) != null
- && cfg.getSharedVariable(ThemeDirectiveModel.NAME) != null
- && cfg.getSharedVariable("jcTagHtml") != null
- && cfg.getSharedVariable(DataTableMethodModel.NAME) != null;
- return pageKept + ":" + othersFilled;
- }
- }
-
- /**
- * Consumer bean already has {@code page} set: that sentinel is kept; the other Juneau reserved names
- * (including {@code jcDataTableHtml}) are still fill-missing stamped.
- */
- @Test void f02_consumerPageSentinel_isKeptAndOtherNamesFilled() throws Exception {
- var c = MockRestClient.buildLax(CollisionConfigHost.class);
- var body = c.get("/probe").accept("text/plain").run().getContent().asString();
- assertEquals("true:true", body,
- () -> "expected page-sentinel-kept=true, other-names-filled=true; got " + body);
- }
-
- @Rest
- public static class SharedConfigDummyHost extends BasicRestServlet {
- private static final long serialVersionUID = 1L;
- static final Configuration SHARED_CFG = new Configuration(Configuration.VERSION_2_3_34);
- static {
- SHARED_CFG.setTemplateLoader(new StringTemplateLoader());
- }
- static final ThreadLocal<RestRequest> CAPTURED = new ThreadLocal<>();
- @Bean public Configuration configuration() { return SHARED_CFG; }
- @RestGet(path="/x")
- public String x(RestRequest req) {
- CAPTURED.set(req);
- return "x";
- }
- }
-
- /**
- * Two {@link ConsoleDataTablesFreemarkerMixin} instances sharing one consumer {@code Configuration} bean
- * splice the console loader once: {@code MultiTemplateLoader} count stays 2.
- */
- @Test void f03_twoMixinInstances_sharedConsumerBean_loaderCountStays2() throws Exception {
- MockRestClient.buildLax(SharedConfigDummyHost.class).get("/x").run();
- var req = SharedConfigDummyHost.CAPTURED.get();
- var m1 = ConsoleDataTablesFreemarkerMixin.create().build();
- var m2 = ConsoleDataTablesFreemarkerMixin.create().build();
- var c1 = m1.resolveConfiguration(req);
- var c2 = m2.resolveConfiguration(req);
- assertSame(SharedConfigDummyHost.SHARED_CFG, c1);
- assertSame(SharedConfigDummyHost.SHARED_CFG, c2);
- assertEquals(2, loaderCount(c1));
- assertEquals(2, loaderCount(c2));
- assertNotNull(c1.getSharedVariable(DataTableMethodModel.NAME));
- assertNotNull(c2.getSharedVariable(DataTableMethodModel.NAME));
- }
-
- private static int loaderCount(Configuration cfg) {
- var loader = cfg.getTemplateLoader();
- return loader instanceof MultiTemplateLoader multi ? multi.getTemplateLoaderCount() : -1;
- }
-
- private static boolean importsDatatablesPackage(java.io.File srcMain) throws Exception {
- assertTrue(srcMain.isDirectory(), () -> "Expected sibling src/main/java not found: " + srcMain.getAbsolutePath());
- try (var files = java.nio.file.Files.walk(srcMain.toPath())) {
- return files
- .filter(p -> p.toString().endsWith(".java"))
- .anyMatch(p -> {
- try {
- return java.nio.file.Files.readString(p).contains("org.apache.juneau.rest.server.datatables");
- } catch (java.io.IOException e) {
- throw new java.io.UncheckedIOException(e);
- }
- });
- }
- }
-}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/releases.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/releases.ftlh
deleted file mode 100644
index d94b9f773d..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/releases.ftlh
+++ /dev/null
@@ -1,22 +0,0 @@
-<#--
- 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.
--->
-<#include "/org/apache/juneau/console/datatables/datatable.ftlh">
-<html>
-<body>
-<@datatable id="releases" rows=releases rowType=rowTypeName/>
-</body>
-</html>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/AssetUrlMethodModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/AssetUrlMethodModel.java
index 5af522d7f9..16f9d90f39 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/AssetUrlMethodModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/AssetUrlMethodModel.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.io.*;
import java.util.*;
import java.util.concurrent.*;
@@ -85,7 +87,7 @@ public final class AssetUrlMethodModel implements TemplateMethodModelEx {
private String token(String path) {
var resource = root + path.substring(1);
try (var in = loader.getResourceAsStream(resource)) {
- if (in != null)
+ if (nn(in))
return ChecksumUtils.hash8(in.readAllBytes());
} catch (IOException e) {
LOG.log(Level.WARNING, e, () -> "assetUrl: cannot read bundled resource '" + resource + "'.");
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirectiveModel.java
index 8820a52332..a65976d416 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirectiveModel.java
@@ -69,21 +69,21 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
/** The shared-variable name this directive registers under. */
public static final String NAME = "card";
- static final Set<String> ATTRS = Set.of("type", "id", "title", "src", "template", "requires");
+ static final Set<String> ATTRS = Set.of("type", "id", "title", "src", "template", "requires", "class");
private static final Set<String> REMOVED_TYPES = Set.of("js", "json", "calendar");
+ private static final Pattern CLASS_TOKEN_RE = Pattern.compile("^[A-Za-z_-][A-Za-z0-9_-]*$");
+
private static final Pattern TYPE_RE = Pattern.compile("^[a-z][a-z0-9-]{0,31}$");
/** Contract base keys copied onto {@link CardSpec}'s own setters rather than {@link CardSpec#body}. */
private static final Set<String> BASE_KEYS = Set.of("id", "type", "title", "src", "template");
- private final boolean devMode;
private final CardRequirements requirements;
private final CardTypeRegistry cardTypes;
- CardDirectiveModel(boolean devMode, CardRequirements requirements, CardTypeRegistry cardTypes) {
- this.devMode = devMode;
+ CardDirectiveModel(CardRequirements requirements, CardTypeRegistry cardTypes) {
this.requirements = requirements;
this.cardTypes = cardTypes;
}
@@ -102,6 +102,7 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
var type = FtlAttrLists.scalar(p, "type");
var authoredId = FtlAttrLists.scalar(p, "id");
var requires = FtlAttrLists.list(p, NAME, "requires");
+ var cssClass = cssClass(p, authoredId);
if (REMOVED_TYPES.contains(type))
throw FtlAttrLists.reject(String.format(
"<@card id='%s'> type='%s' was removed in 10.0.0; use type='html' with a <template>, or a registered card type.",
@@ -110,7 +111,7 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
throw FtlAttrLists.reject(String.format("<@card id='%s'> type='%s' must match ^[a-z][a-z0-9-]{0,31}$.", authoredId, type));
var cap = PageCapture.get(env);
- if (cap == null || ! cap.pageOpen)
+ if (n(cap) || ! cap.pageOpen)
throw FtlAttrLists.reject("<@card> must be nested inside <@page>.");
if (cap.cardOpen)
throw FtlAttrLists.reject("<@card> cannot be nested inside another <@card>.");
@@ -134,14 +135,31 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
var cardType = type.isEmpty() ? "html" : type;
var id = authoredId.isEmpty() && eq(cardType, "html") ? cap.nextCardId() : authoredId;
if (eq(cardType, "run-view"))
- runViewCard(cap, id, title, src, template, markup);
+ runViewCard(cap, id, title, src, template, markup, cssClass);
else if (eq(cardType, "html"))
- htmlCard(cap, id, title, src, template, markup);
+ htmlCard(cap, id, title, src, template, markup, cssClass);
else
- registryCard(cap, cardType, id, title, src, template, markup, env.getCurrentTemplate().getName());
+ registryCard(cap, cardType, id, title, src, template, markup, cssClass);
recordRequirements(cap, cardType, id, requires);
}
+ /**
+ * Reads {@code class=}: a space-separated list of CSS class tokens, each matching
+ * {@code [A-Za-z_-][A-Za-z0-9_-]*}. Returns the tokens joined by single spaces, or {@code ""} when absent.
+ */
+ private static String cssClass(Map<String, TemplateModel> p, String authoredId) throws TemplateModelException {
+ var raw = FtlAttrLists.scalar(p, "class");
+ if (raw.isEmpty())
+ return "";
+ var tokens = raw.split("\\s+");
+ for (var t : tokens)
+ if (! CLASS_TOKEN_RE.matcher(t).matches())
+ throw FtlAttrLists.reject(String.format(
+ "<@card id='%s'> class='%s' must be a space-separated list of CSS class names matching [A-Za-z_-][A-Za-z0-9_-]*.",
+ authoredId, raw));
+ return String.join(" ", tokens);
+ }
+
// The one place a card's packs are recorded; <@page> resolves them after its body.
private void recordRequirements(PageCapture cap, String type, String id, List<String> requires) throws TemplateModelException {
try {
@@ -151,7 +169,8 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
}
}
- private static void htmlCard(PageCapture cap, String id, String title, String src, String template, String markup)
+ private static void htmlCard(PageCapture cap, String id, String title, String src, String template, String markup,
+ String cssClass)
throws TemplateModelException {
var hasBody = ! markup.isBlank();
var sources = (hasBody ? 1 : 0) + (src.isEmpty() ? 0 : 1) + (template.isEmpty() ? 0 : 1);
@@ -167,6 +186,8 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
card.src(src);
if (! template.isEmpty())
card.template(template);
+ if (! cssClass.isEmpty())
+ card.cssClass(cssClass);
cap.addCard(card, hasBody ? markup : null);
}
@@ -176,7 +197,7 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
* generic passthrough for an unregistered type).
*/
private void registryCard(PageCapture cap, String type, String id, String title, String src, String template,
- String markup, String ftlName) throws TemplateModelException {
+ String markup, String cssClass) throws TemplateModelException {
if (id.isEmpty())
throw FtlAttrLists.reject(String.format("<@card type=\"%s\"> requires id=.", type));
var hasBody = ! markup.isBlank();
@@ -205,12 +226,13 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
JsonMap card;
try {
card = cardTypes.toCard(source.build());
- if (eq(type, "datatables") && card.get("table") instanceof JsonMap t && t.get("view") instanceof Map)
- DatatablesCardType.reconcileViewVersion(id, t, ftlName, devMode);
} catch (IllegalArgumentException e) {
throw FtlAttrLists.reject(e.getMessage());
}
- cap.addCard(toCardSpec(card), captured[0] ? markup : null);
+ var spec = toCardSpec(card);
+ if (! cssClass.isEmpty())
+ spec.cssClass(cssClass);
+ cap.addCard(spec, captured[0] ? markup : null);
}
/** Converts the flat {@link CardTypeRegistry#toCard} result into the {@link CardSpec} {@link PageCapture} wants. */
@@ -239,7 +261,7 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
"unchecked" // JSON5 object keys are always strings.
})
private static void runViewCard(PageCapture cap, String id, String title, String src, String template,
- String markup) throws TemplateModelException {
+ String markup, String cssClass) throws TemplateModelException {
if (id.isEmpty())
throw FtlAttrLists.reject("<@card type=\"run-view\"> requires id=.");
if (! (src.isEmpty() && template.isEmpty()))
@@ -260,7 +282,7 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
throw FtlAttrLists.reject(String.format(
"<@card id='%s'> type='run-view' unknown key '%s'; allowed: contractVersion, runView.", id, k));
var version = envelope.get("contractVersion");
- if (! "1".equals(version))
+ if (! eq("1", version))
throw FtlAttrLists.reject(String.format(
"<@card id='%s'> type='run-view' requires contractVersion: '1'; got '%s'.", id, version));
if (! (envelope.get("runView") instanceof Map<?,?> runView))
@@ -269,6 +291,8 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
var card = CardSpec.of("run-view", id);
if (! title.isEmpty())
card.title(title);
+ if (! cssClass.isEmpty())
+ card.cssClass(cssClass);
card.body("runView", def.toMap());
cap.addCard(card, null);
}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardSpec.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardSpec.java
index 859cc72d36..8e3dffead2 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardSpec.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardSpec.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.util.*;
/**
@@ -40,6 +42,7 @@ public final class CardSpec {
private String src;
private String template;
private boolean bare;
+ private String cssClass;
private final Map<String,Object> body = new LinkedHashMap<>();
private CardSpec(String type, String id) {
@@ -69,6 +72,9 @@ public final class CardSpec {
/** @param v Whether an html card is inserted without the {@code .jc-card} wrapper. @return This object. */
public CardSpec bare(boolean v) { bare = v; return this; }
+ /** @param v Space-separated CSS class names stamped on the card wrapper element. @return This object. */
+ public CardSpec cssClass(String v) { cssClass = v; return this; }
+
/** @param key A type-specific field. @param value Its value. @return This object. */
public CardSpec body(String key, Object value) { body.put(key, value); return this; }
@@ -86,10 +92,11 @@ public final class CardSpec {
var m = new LinkedHashMap<String,Object>();
m.put("id", id);
m.put("type", type);
- if (title != null) m.put("title", title);
- if (src != null) m.put("src", src);
- if (template != null) m.put("template", template);
+ if (nn(title)) m.put("title", title);
+ if (nn(src)) m.put("src", src);
+ if (nn(template)) m.put("template", template);
if (bare) m.put("bare", true);
+ if (nn(cssClass)) m.put("class", cssClass);
m.putAll(body);
return m;
}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleDirectiveModel.java
index 480c5f4b3e..e8055b85d5 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleDirectiveModel.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.io.*;
import java.util.*;
@@ -78,7 +80,7 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
var req = FreemarkerRenderScope.request();
- if (req == null)
+ if (n(req))
throw FtlAttrLists.reject("<@console> needs FreemarkerRenderScope.request() (renderer wrap).");
var cap = PageCapture.of(env);
@@ -111,7 +113,7 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
cap.consoleOpen = true;
try {
- if (body != null)
+ if (nn(body))
body.render(cap.consoleBuffer());
} finally {
cap.consoleOpen = false;
@@ -119,14 +121,14 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
cap.checkConsoleClose();
cap.resolveActiveNav();
- if (cap.themeCssUrl == null) {
+ if (n(cap.themeCssUrl)) {
cap.themeCssUrl = ConsoleChromeMixin.themeAssetUrl(req, themeName);
cap.theme(themeName);
}
var token = req.getAttribute(LoopbackBoundaryFilter.TOKEN_ATTRIBUTE).asString().orElse(null);
var csrfHeader = req.getAttribute(LoopbackBoundaryFilter.HEADER_ATTRIBUTE).asString().orElse(null);
- var hasCsrf = token != null && ! token.isBlank();
+ var hasCsrf = inb(token);
if (devMode) {
var findings = PageContractSchema.get().validate(cap.toContractJson(), cap.templates().keySet());
@@ -148,10 +150,10 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
out.write("<link rel=\"icon\" href=\"" + attrEscape(favicon) + "\">\n");
out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(ConsoleChromeMixin.chromeCssUrl(req)) + "\">\n");
out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(cap.themeCssUrl) + "\">\n");
- if (cap.themeOverrideBlock != null)
+ if (nn(cap.themeOverrideBlock))
out.write("<style>" + cap.themeOverrideBlock + "</style>\n");
// App CSS that must lose to page-local css=: emitted BEFORE the page css so a page rule still wins.
- if (cap.headBeforePageCss != null)
+ if (nn(cap.headBeforePageCss))
out.write(cap.headBeforePageCss);
for (var href : cap.vendorCss())
out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(href) + "\" data-toolkit-css>\n");
@@ -159,17 +161,17 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(href) + "\">\n");
for (var href : cap.runtimeCss())
out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(href) + "\" data-toolkit-css>\n");
- if (cap.head != null)
+ if (nn(cap.head))
out.write(cap.head);
out.write("\n</head>\n");
out.write("<body");
if (hasCsrf) {
out.write(" data-juneau-csrf=\"" + attrEscape(token) + "\"");
- if (csrfHeader != null && ! csrfHeader.isBlank())
+ if (inb(csrfHeader))
out.write(" data-juneau-csrf-header=\"" + attrEscape(csrfHeader) + "\"");
}
- if (cap.bodyAttrs != null && ! cap.bodyAttrs.isBlank())
+ if (inb(cap.bodyAttrs))
out.write(" " + cap.bodyAttrs.trim());
out.write(">\n");
@@ -182,11 +184,11 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
for (var src : cap.runtimeJs())
out.write("<script src=\"" + attrEscape(src) + "\" data-toolkit-js></script>\n");
// App scripts that must run after the toolkit pack but before the page-local init= scripts.
- if (cap.scriptsAfterToolkit != null)
+ if (nn(cap.scriptsAfterToolkit))
out.write(cap.scriptsAfterToolkit);
for (var src : cap.initScripts())
out.write("<script src=\"" + attrEscape(src) + "\"></script>\n");
- if (cap.scripts != null)
+ if (nn(cap.scripts))
out.write(cap.scripts);
out.write("\n</body>\n</html>\n");
cap.consoleDone = true;
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin.java
index ad61a5868a..48b057e22b 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.util.*;
import org.apache.juneau.rest.server.*;
@@ -200,7 +202,7 @@ public class ConsoleFreemarkerMixin extends FreemarkerMixin {
return;
var console = new ClassTemplateLoader(getClass().getClassLoader(), "");
var existing = cfg.getTemplateLoader();
- if (existing == null)
+ if (n(existing))
cfg.setTemplateLoader(console);
else
cfg.setTemplateLoader(new MultiTemplateLoader(new TemplateLoader[]{ existing, console }));
@@ -213,11 +215,11 @@ public class ConsoleFreemarkerMixin extends FreemarkerMixin {
*/
private static boolean reservedTemplateResolves(Configuration cfg) {
var loader = cfg.getTemplateLoader();
- if (loader == null)
+ if (n(loader))
return false;
try {
var source = loader.findTemplateSource(BASE_TEMPLATE_PATH);
- if (source == null)
+ if (n(source))
return false;
loader.closeTemplateSource(source);
return true;
@@ -230,32 +232,32 @@ public class ConsoleFreemarkerMixin extends FreemarkerMixin {
* Sets each reserved console shared variable only when that name is unset on {@code cfg}.
*/
private void fillMissingConsoleSharedVariables(Configuration cfg) {
- if (cfg.getSharedVariable(TagMethodModel.NAME) == null)
+ if (n(cfg.getSharedVariable(TagMethodModel.NAME)))
cfg.setSharedVariable(TagMethodModel.NAME, new TagMethodModel());
- if (cfg.getSharedVariable(PageDirectiveModel.NAME) == null) {
+ if (n(cfg.getSharedVariable(PageDirectiveModel.NAME))) {
cfg.setSharedVariable(PageDirectiveModel.NAME, new PageDirectiveModel(chromeTemplate, packs));
}
- if (cfg.getSharedVariable(CardDirectiveModel.NAME) == null)
- cfg.setSharedVariable(CardDirectiveModel.NAME, new CardDirectiveModel(devMode, cardRequirements, cardTypes));
- if (cfg.getSharedVariable(NavigationDirectiveModel.NAME) == null)
+ if (n(cfg.getSharedVariable(CardDirectiveModel.NAME)))
+ cfg.setSharedVariable(CardDirectiveModel.NAME, new CardDirectiveModel(cardRequirements, cardTypes));
+ if (n(cfg.getSharedVariable(NavigationDirectiveModel.NAME)))
cfg.setSharedVariable(NavigationDirectiveModel.NAME, new NavigationDirectiveModel());
- if (cfg.getSharedVariable(NodeDirectiveModel.NAME) == null)
+ if (n(cfg.getSharedVariable(NodeDirectiveModel.NAME)))
cfg.setSharedVariable(NodeDirectiveModel.NAME, new NodeDirectiveModel());
- if (cfg.getSharedVariable(ThemeDirectiveModel.NAME) == null)
+ if (n(cfg.getSharedVariable(ThemeDirectiveModel.NAME)))
cfg.setSharedVariable(ThemeDirectiveModel.NAME, new ThemeDirectiveModel());
- if (cfg.getSharedVariable(TokenDirectiveModel.NAME) == null)
+ if (n(cfg.getSharedVariable(TokenDirectiveModel.NAME)))
cfg.setSharedVariable(TokenDirectiveModel.NAME, new TokenDirectiveModel());
- if (cfg.getSharedVariable(ConsoleDirectiveModel.NAME) == null)
+ if (n(cfg.getSharedVariable(ConsoleDirectiveModel.NAME)))
cfg.setSharedVariable(ConsoleDirectiveModel.NAME, new ConsoleDirectiveModel(devMode));
- if (cfg.getSharedVariable(MainDirectiveModel.NAME) == null)
+ if (n(cfg.getSharedVariable(MainDirectiveModel.NAME)))
cfg.setSharedVariable(MainDirectiveModel.NAME, new MainDirectiveModel());
- if (cfg.getSharedVariable(HasToolkitMethodModel.NAME) == null)
+ if (n(cfg.getSharedVariable(HasToolkitMethodModel.NAME)))
cfg.setSharedVariable(HasToolkitMethodModel.NAME, new HasToolkitMethodModel());
- if (adopterLoader != null && cfg.getSharedVariable(AssetUrlMethodModel.NAME) == null)
+ if (nn(adopterLoader) && n(cfg.getSharedVariable(AssetUrlMethodModel.NAME)))
cfg.setSharedVariable(AssetUrlMethodModel.NAME, new AssetUrlMethodModel(adopterLoader, adopterRoot, devMode));
// The seven capture-only slot directives share one parameterized class, one instance registered per slot name.
for (var slot : ConsoleSlotDirectiveModel.SLOT_NAMES)
- if (cfg.getSharedVariable(slot) == null)
+ if (n(cfg.getSharedVariable(slot)))
cfg.setSharedVariable(slot, new ConsoleSlotDirectiveModel(slot));
}
@@ -331,7 +333,7 @@ public class ConsoleFreemarkerMixin extends FreemarkerMixin {
* @return This object.
*/
public Builder registerToolkitPack(ToolkitPack pack) {
- extraPacks.add(Objects.requireNonNull(pack, "pack"));
+ extraPacks.add(rnn(pack));
return this;
}
@@ -432,8 +434,8 @@ public class ConsoleFreemarkerMixin extends FreemarkerMixin {
*/
public Builder adopterAssets(ClassLoader loader, String resourceRoot) {
// Q: Use Shorts here and in this module.
- this.adopterLoader = Objects.requireNonNull(loader, "loader");
- this.adopterRoot = Objects.requireNonNull(resourceRoot, "resourceRoot");
+ this.adopterLoader = rnn(loader);
+ this.adopterRoot = rnn(resourceRoot);
return this;
}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleOutputCardType.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleOutputCardType.java
index 26343d7742..eb3e7c994e 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleOutputCardType.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleOutputCardType.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.util.*;
import org.apache.juneau.marshall.collections.*;
@@ -58,7 +60,7 @@ public final class ConsoleOutputCardType implements CardTypeHandler {
if (! BODY_KEYS.contains(k))
throw source.error("type='console-output' unknown key '%s'; allowed: contractVersion, output.", k);
var version = envelope.get("contractVersion");
- if (! "1".equals(version))
+ if (! eq("1", version))
throw source.error("type='console-output' requires contractVersion: '1'; got '%s'.", version);
if (! (envelope.get("output") instanceof Map<?,?> output))
throw source.error("type='console-output' requires an output object.");
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleSlotDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleSlotDirectiveModel.java
index 1663517ea0..9fae6b6860 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleSlotDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleSlotDirectiveModel.java
@@ -86,9 +86,9 @@ public final class ConsoleSlotDirectiveModel implements TemplateDirectiveModel {
/** @return The attributes this slot accepts (read by {@link ConsoleTemplateValidator}). */
Set<String> attrs() {
- if (HEAD.equals(slot) || SCRIPTS.equals(slot))
+ if (eq(HEAD, slot) || eq(SCRIPTS, slot))
return Set.of(PHASE);
- if (FOOTER.equals(slot))
+ if (eq(FOOTER, slot))
return Set.of(TEXT);
return Set.of();
}
@@ -107,14 +107,14 @@ public final class ConsoleSlotDirectiveModel implements TemplateDirectiveModel {
var phase = FtlAttrLists.scalar(p, PHASE);
if (! phase.isEmpty()) {
- var ok = (HEAD.equals(slot) && eq(phase, PHASE_BEFORE_PAGE_CSS))
- || (SCRIPTS.equals(slot) && eq(phase, PHASE_AFTER_TOOLKIT));
+ var ok = (eq(HEAD, slot) && eq(phase, PHASE_BEFORE_PAGE_CSS))
+ || (eq(SCRIPTS, slot) && eq(phase, PHASE_AFTER_TOOLKIT));
if (! ok)
throw FtlAttrLists.reject("<@" + slot + "> unknown phase '" + phase + "'.");
}
var cap = PageCapture.get(env);
- if (cap == null || ! cap.consoleOpen)
+ if (n(cap) || ! cap.consoleOpen)
throw FtlAttrLists.reject(String.format("<@%s> must be nested inside <@console>.", slot));
var markup = PageCapture.render(body);
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator.java
index f3fce1e040..68444906d6 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator.java
@@ -176,7 +176,7 @@ public final class ConsoleTemplateValidator {
*/
public List<Finding> validate(String templateName) {
var src = load(templateName);
- if (src == null)
+ if (n(src))
throw new IllegalArgumentException(String.format("Template '%s' not found under templateRoot or classpathRoot.", templateName));
return validateSource(templateName, src);
}
@@ -188,7 +188,7 @@ public final class ConsoleTemplateValidator {
* @throws IllegalStateException If no template root is set.
*/
public List<Finding> validateAll() {
- if (templateRoot == null)
+ if (n(templateRoot))
throw new IllegalStateException("validateAll() requires templateRoot(Path); a classpath root cannot be listed.");
try (Stream<Path> s = Files.walk(templateRoot)) {
var names = s.filter(Files::isRegularFile)
@@ -218,9 +218,9 @@ public final class ConsoleTemplateValidator {
var macros = new HashSet<String>(allowed);
var namespaces = new HashSet<String>();
collectScope(name, text, macros, namespaces, new HashSet<>());
- if (chromeTemplate != null && neq(chromeTemplate, name)) {
+ if (nn(chromeTemplate) && neq(chromeTemplate, name)) {
var chrome = load(chromeTemplate);
- if (chrome == null)
+ if (n(chrome))
throw new IllegalArgumentException(String.format("Chrome template '%s' not found under templateRoot or classpathRoot.", chromeTemplate));
collectScope(chromeTemplate, blankComments(chrome), macros, namespaces, new HashSet<>());
}
@@ -242,7 +242,7 @@ public final class ConsoleTemplateValidator {
if (closing) {
if ((dname.equals(NodeDirectiveModel.NAME) || dname.equals(NavigationDirectiveModel.NAME)) && siblings.size() > 1)
siblings.pop();
- } else if (known == null) {
+ } else if (n(known)) {
var dot = dname.indexOf('.');
var ok = dot > 0 ? namespaces.contains(dname.substring(0, dot)) : macros.contains(dname);
if (! ok)
@@ -254,7 +254,7 @@ public final class ConsoleTemplateValidator {
add(out, name, lines, at, "unknown-attribute", String.format("<@%s> unknown attribute '%s'.", dname, a));
for (var a : STRICT.getOrDefault(dname, Set.of())) {
var v = attrs.get(a);
- if (v != null && v.literal && !eqa(v.value, "true", "false"))
+ if (nn(v) && v.literal && !eqa(v.value, "true", "false"))
add(out, name, lines, at, "strict-boolean", String.format("<@%s> %s= must be true or false; got '%s'.", dname, a, v.value));
}
var id = attrs.get("id");
@@ -264,26 +264,26 @@ public final class ConsoleTemplateValidator {
siblings.push(new HashSet<>());
}
case NodeDirectiveModel.NAME -> {
- if (id != null && id.literal && ! siblings.peek().add(id.value))
+ if (nn(id) && id.literal && ! siblings.peek().add(id.value))
add(out, name, lines, at, "duplicate-id", String.format("<@node id='%s'> duplicates a sibling id.", id.value));
if (! selfClosing)
siblings.push(new HashSet<>());
}
case CardDirectiveModel.NAME -> {
- if (id != null && id.literal && ! cardIds.add(id.value))
+ if (nn(id) && id.literal && ! cardIds.add(id.value))
add(out, name, lines, at, "duplicate-id", String.format("<@card id='%s'> duplicates an existing card id.", id.value));
var type = attrs.get("type");
- if (type != null && type.literal && REMOVED_TYPES.contains(type.value))
+ if (nn(type) && type.literal && REMOVED_TYPES.contains(type.value))
add(out, name, lines, at, "removed-card-type", String.format(
"<@card id='%s'> type='%s' was removed in 10.0.0; use type='html' with a <template>, or a registered card type.",
- id == null ? "" : id.value, type.value));
+ n(id) ? "" : id.value, type.value));
var tpl = attrs.get("template");
- if (tpl != null && tpl.literal)
+ if (nn(tpl) && tpl.literal)
templateRefs.add(new Object[]{tpl.value, at});
- if (type != null && type.literal && eq(type.value, "datatables") && ! selfClosing)
- checkViewVersion(out, name, lines, text, m.end(), id == null ? "" : id.value);
+ if (nn(type) && type.literal && eq(type.value, "datatables") && ! selfClosing)
+ checkViewVersion(out, name, lines, text, m.end(), n(id) ? "" : id.value);
if (! selfClosing)
- checkCardBody(out, name, lines, at, text, m.end(), id, type, attrs.get("src"));
+ checkCardBody(out, name, lines, at, text, m.end(), id, attrs.get("src"));
}
case ConsoleDirectiveModel.NAME -> {
if (consoleAt < 0)
@@ -311,7 +311,7 @@ public final class ConsoleTemplateValidator {
var a = ASSET_REF.matcher(text);
while (a.find()) {
- var url = a.group(1) != null ? a.group(1) : a.group(2);
+ var url = nn(a.group(1)) ? a.group(1) : a.group(2);
if (! url.contains("${") && ! url.contains("<#") && JUNEAU_ASSET.matcher(url).find())
add(out, name, lines, a.start(), "hardcoded-asset-url", String.format(
"Hard-coded Juneau asset URL '%s' is unversioned and goes stale in browser caches; use viewAssetUrl(...), consoleJsUrl(...), chromeCssUrl(...) or themeAssetUrl(...) so it carries ?v= (an adopter's own assets can use assetUrl(...)).", url));
@@ -358,33 +358,28 @@ public final class ConsoleTemplateValidator {
private static final Set<String> RESERVED_BODY_KEYS = Set.of("id", "type", "title", "src", "template", "ref");
/**
- * The C2 card-body rules: {@code card-src-and-body}, {@code inline-slot-meta} and {@code card-reserved-key}.
+ * The C2 card-body rules: {@code card-src-and-body} and {@code card-reserved-key}.
* Reads the body the same way {@link #checkViewVersion} does, up to the next {@code </@card}.
*/
- private static void checkCardBody(List<Finding> out, String name, int[] lines, int at, String text, int bodyStart, Value id, Value type, Value src) {
+ private static void checkCardBody(List<Finding> out, String name, int[] lines, int at, String text, int bodyStart, Value id, Value src) {
var end = text.indexOf("</@card", bodyStart);
var body = text.substring(bodyStart, end < 0 ? text.length() : end);
- var cardId = id == null ? "" : id.value;
+ var cardId = n(id) ? "" : id.value;
var trimmed = body.trim();
- if (! trimmed.isEmpty() && src != null && src.literal)
+ if (! trimmed.isEmpty() && nn(src) && src.literal)
add(out, name, lines, at, "card-src-and-body", String.format(
"<@card id='%s'> has both src='%s' and a body; use exactly one.", cardId, src.value));
if (trimmed.isEmpty() || body.contains("${"))
return;
var keys = topLevelJson5Keys(trimmed);
- // Same detection as DatatablesCardType's escape hatch (a top-level 'view' object).
- if (type != null && type.literal && eq(type.value, "datatables") && keys.contains("view"))
- add(out, name, lines, at, "inline-slot-meta", String.format(
- "<@card id='%s'> body is a pre-built SLOT_META (has a 'view' object); author the table catalog "
- + "in author shape instead. This becomes an error at GC-1.", cardId));
for (var k : keys)
if (RESERVED_BODY_KEYS.contains(k))
add(out, name, lines, at, "card-reserved-key", String.format(
"<@card id='%s'> body key '%s' is reserved; set it as an attribute.", cardId, k));
}
- // Hand-rolled, like attrs() below: finds JSON5 object keys at brace-depth 0, skipping quoted strings. Not a
- // general parser — enough to catch literal top-level keys for the three card-body lint rules above.
+ // Hand-rolled, like attrs() below: finds JSON5 object keys at brace-depth 0, skipping quoted strings (backslash escapes honored). Not a
+ // general parser — enough to catch literal top-level keys for the two card-body lint rules above.
private static Set<String> topLevelJson5Keys(String body) {
var s = body.trim();
if (s.startsWith("{") && s.endsWith("}"))
@@ -395,8 +390,11 @@ public final class ConsoleTemplateValidator {
while (i < s.length()) {
var c = s.charAt(i);
if (c == '"' || c == '\'') {
- var close = s.indexOf(c, i + 1);
- i = close < 0 ? s.length() : close + 1;
+ // Honor backslash escapes in both quote styles: skip the escaped character wholesale.
+ var j = i + 1;
+ while (j < s.length() && s.charAt(j) != c)
+ j += s.charAt(j) == '\\' ? 2 : 1;
+ i = j >= s.length() ? s.length() : j + 1;
} else if (c == '{' || c == '[') {
depth++;
i++;
@@ -426,8 +424,8 @@ public final class ConsoleTemplateValidator {
while (m.find()) {
String v;
boolean literal;
- if (m.group(2) != null || m.group(3) != null) {
- v = m.group(2) != null ? m.group(2) : m.group(3);
+ if (nn(m.group(2)) || nn(m.group(3))) {
+ v = nn(m.group(2)) ? m.group(2) : m.group(3);
literal = ! v.contains("${");
} else {
v = m.group(4);
@@ -451,7 +449,7 @@ public final class ConsoleTemplateValidator {
while (inc.find()) {
var target = resolve(name, inc.group(1));
var src = load(target);
- if (src != null)
+ if (nn(src))
collectScope(target, blankComments(src), macros, namespaces, seen);
}
}
@@ -467,14 +465,14 @@ public final class ConsoleTemplateValidator {
private String load(String name) {
var rel = name.startsWith("/") ? name.substring(1) : name;
try {
- if (templateRoot != null) {
+ if (nn(templateRoot)) {
var p = templateRoot.resolve(rel);
if (Files.isRegularFile(p))
return Files.readString(p, UTF_8);
}
- if (classpathRoot != null) {
+ if (nn(classpathRoot)) {
var s = resource(classpathRoot + rel);
- if (s != null)
+ if (nn(s))
return s;
}
return name.startsWith("/") ? resource(name) : null;
@@ -485,10 +483,10 @@ public final class ConsoleTemplateValidator {
private static String resource(String absPath) throws IOException {
var cl = Thread.currentThread().getContextClassLoader();
- if (cl == null)
+ if (n(cl))
cl = ConsoleTemplateValidator.class.getClassLoader();
try (var in = cl.getResourceAsStream(absPath.substring(1))) {
- return in == null ? null : new String(in.readAllBytes(), UTF_8);
+ return n(in) ? null : new String(in.readAllBytes(), UTF_8);
}
}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType.java
index 8d6fe31674..04bcfd1bd0 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.util.*;
import java.util.logging.*;
@@ -49,6 +51,8 @@ public final class DatatablesCardType implements CardTypeHandler {
private static final String E27 = "type='datatables' requires src= or a body with 'columns' and exactly one of 'dataUrl' or 'rows'.";
+ private static final String E28 = "type='datatables' body is a pre-built SLOT_META envelope; remove 'contractVersion', 'layout' and 'view' and author the catalog form.";
+
private static final Set<String> KNOWN_COLUMN_KEYS = Set.of(
"key", "data", "label", "title", "search", "render", "href", "className",
"orderable", "searchable", "defaultVisible", "pinned", "formats",
@@ -65,8 +69,8 @@ public final class DatatablesCardType implements CardTypeHandler {
@Override
@SuppressWarnings("unchecked")
public JsonMap toFragment(CardSource source) {
- var hasSrc = source.src() != null && ! source.src().isEmpty();
- var hasBody = source.body() != null && ! source.body().isEmpty();
+ var hasSrc = nn(source.src()) && ! source.src().isEmpty();
+ var hasBody = nn(source.body()) && ! source.body().isEmpty();
if (hasSrc && hasBody)
throw source.error("type='datatables' takes src= or a body, not both.");
if (hasSrc)
@@ -76,18 +80,13 @@ public final class DatatablesCardType implements CardTypeHandler {
var catalog = source.json(); // E-25 for a non-JSON5 body (the dropped bare-URL form), E-24 for bad JSON5.
- // The pre-built SLOT_META escape hatch (until GC-1): pass through unchanged, no further validation.
- // CardDirectiveModel calls reconcileViewVersion on it, since only it knows the template name and devMode.
- if (catalog.get("view") instanceof Map) {
- var frag = new JsonMap();
- frag.put("table", catalog);
- return frag;
- }
+ if (catalog.get("view") instanceof Map)
+ throw source.error(E28);
var columns = catalog.getList("columns");
- var hasDataUrl = catalog.get("dataUrl") != null;
+ var hasDataUrl = nn(catalog.get("dataUrl"));
var hasRows = catalog.containsKey("rows");
- if (columns == null || hasDataUrl == hasRows)
+ if (n(columns) || hasDataUrl == hasRows)
throw source.error(E27);
if (hasRows)
validateRows(source, catalog);
@@ -100,7 +99,7 @@ public final class DatatablesCardType implements CardTypeHandler {
throw source.error("columns['%s'] requires 'key'.", i);
var col = (Map<String,Object>) raw;
var ident = columnIdent(col);
- if (ident == null)
+ if (n(ident))
throw source.error("columns['%s'] requires 'key'.", i);
for (var key : col.keySet())
if (! KNOWN_COLUMN_KEYS.contains(key))
@@ -111,13 +110,13 @@ public final class DatatablesCardType implements CardTypeHandler {
resolveSearch(source, col, ident);
}
- if (catalog.get("bulk") != null && catalog.get("selection") == null)
+ if (nn(catalog.get("bulk")) && n(catalog.get("selection")))
throw source.error("sets bulk without selection.");
checkAggregateEndpoints(catalog);
var frag = new JsonMap();
var page = catalog.remove("page");
- if (page != null)
+ if (nn(page))
frag.put("page", page);
frag.put("table", catalog);
return frag;
@@ -131,45 +130,18 @@ public final class DatatablesCardType implements CardTypeHandler {
if (! (r instanceof Map<?, ?>))
throw source.error("type='datatables' each 'rows' element must be an object.");
// Q: Use Shorts here and elsewhere in this module.
- if ("server".equals(catalog.getString("dataMode")))
+ if (eq("server", catalog.getString("dataMode")))
throw source.error("type='datatables' inline 'rows' cannot be combined with dataMode:'server'; use 'dataUrl'.");
- if (catalog.get("pollIntervalMs") != null)
+ if (nn(catalog.get("pollIntervalMs")))
throw source.error("type='datatables' inline 'rows' cannot be combined with 'pollIntervalMs' (nothing to poll); use 'dataUrl'.");
if (list.size() > ROWS_WARN_THRESHOLD)
LOG.log(Level.WARNING, "<@card type=\"datatables\"> inline 'rows' has {0} entries; prefer 'dataUrl' for large data sets.", list.size());
}
- /**
- * Stamps a missing slot/view {@code contractVersion} on a pre-built SLOT_META and warns (throws in devMode) on a
- * stale pinned view version.
- */
- @SuppressWarnings({
- "unchecked" // The view of a pre-built envelope is a parsed JsonMap; it is mutated in place to inject the version.
- })
- static void reconcileViewVersion(String cardId, JsonMap slot, String template, boolean devMode) {
- if (! slot.containsKey("contractVersion"))
- slot.put("contractVersion", ViewsMixin.SLOT_CONTRACT_VERSION);
- var view = (Map<String,Object>)slot.get("view");
- var stated = view.get("contractVersion");
- if (stated == null) {
- view.put("contractVersion", ViewsMixin.CONTRACT_VERSION);
- return;
- }
- if (ViewsMixin.CONTRACT_VERSION.equals(String.valueOf(stated)))
- return;
- var msg = String.format(
- "Template '%s' card '%s': view.contractVersion is '%s' but the runtime ViewsMixin.CONTRACT_VERSION is '%s'. "
- + "Omit view.contractVersion to track the runtime automatically.",
- template, cardId, stated, ViewsMixin.CONTRACT_VERSION);
- LOG.log(Level.WARNING, msg);
- if (devMode)
- throw new IllegalArgumentException(msg);
- }
-
private static String columnIdent(Map<String,Object> col) {
var data = col.get("data");
var key = col.get("key");
- return data != null ? data.toString() : (key != null ? key.toString() : null);
+ return nn(data) ? data.toString() : (nn(key) ? key.toString() : null);
}
/** E-31: a {@code contractVersion} key anywhere in the catalog (table, detail, quickStats, bulk) — JS stamps versions. */
@@ -188,9 +160,9 @@ public final class DatatablesCardType implements CardTypeHandler {
@SuppressWarnings("unchecked")
private static void resolveSearch(CardSource source, Map<String,Object> col, String ident) {
var searchType = str(col.get("searchType"));
- if (searchType == null || searchType.isBlank())
+ if (ib(searchType))
return; // Non-searchable column: no search block.
- if (SearchType.fromWire(searchType) == null)
+ if (n(SearchType.fromWire(searchType)))
throw source.error("column '%s' searchType '%s' is unknown; known: '%s'.", ident, searchType, knownSearchTypes());
var customs = new LinkedHashMap<String,String>(); // name -> help
@@ -207,7 +179,7 @@ public final class DatatablesCardType implements CardTypeHandler {
allow = new ArrayList<>();
for (var n : a) {
var name = str(n);
- if (! customs.containsKey(name) && SearchOperatorSet.standard().get(name) == null)
+ if (! customs.containsKey(name) && n(SearchOperatorSet.standard().get(name)))
throw source.error("column '%s' searchOperators names unknown operator '%s'; known: '%s'.",
ident, name, knownOperators(customs.keySet()));
allow.add(name);
@@ -255,10 +227,10 @@ public final class DatatablesCardType implements CardTypeHandler {
return;
for (var raw : actions) {
var action = (Map<String,Object>) raw;
- if (! "aggregate".equals(action.get("mode")))
+ if (! eq("aggregate", action.get("mode")))
continue;
var endpoint = str(action.get("endpoint"));
- if (endpoint != null && endpoint.matches(".*\\{[^}]+\\}.*"))
+ if (nn(endpoint) && endpoint.matches(".*\\{[^}]+\\}.*"))
throw new IllegalArgumentException(String.format(
"Bulk action '%s' has mode 'aggregate' but its endpoint '%s' contains a {field} token; "
+ "aggregate endpoints receive {ids} in the body.", action.get("id"), endpoint));
@@ -266,6 +238,6 @@ public final class DatatablesCardType implements CardTypeHandler {
}
private static String str(Object o) {
- return o == null ? null : o.toString();
+ return n(o) ? null : o.toString();
}
}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/FtlAttrLists.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/FtlAttrLists.java
index d1a75c1494..1174f3e368 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/FtlAttrLists.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/FtlAttrLists.java
@@ -59,10 +59,10 @@ final class FtlAttrLists {
static String scalar(Map<String, TemplateModel> params, String name) throws TemplateModelException {
var raw = params.get(name);
- if (raw == null)
+ if (n(raw))
return "";
var u = DeepUnwrap.unwrap(raw);
- return u == null ? "" : String.valueOf(u).trim();
+ return n(u) ? "" : String.valueOf(u).trim();
}
@SuppressWarnings({
@@ -70,7 +70,7 @@ final class FtlAttrLists {
})
static List<String> list(Map<String, TemplateModel> params, String directive, String name) throws TemplateModelException {
var raw = params.get(name);
- if (raw == null)
+ if (n(raw))
return List.of();
var u = DeepUnwrap.unwrap(raw);
if (u instanceof String s) {
@@ -87,7 +87,7 @@ final class FtlAttrLists {
if (u instanceof Collection<?> c) {
var out = new ArrayList<String>();
for (var item : c) {
- if (item == null)
+ if (n(item))
continue;
var t = String.valueOf(item).trim();
if (! t.isEmpty())
@@ -135,14 +135,14 @@ final class FtlAttrLists {
static boolean strictBoolean(Map<String, TemplateModel> params, String directive, String name, boolean dflt)
throws TemplateModelException {
var raw = params.get(name);
- if (raw == null)
+ if (n(raw))
return dflt;
if (raw instanceof TemplateBooleanModel b)
return b.getAsBoolean();
var u = DeepUnwrap.unwrap(raw);
if (u instanceof Boolean b)
return b;
- var s = u == null ? "" : String.valueOf(u);
+ var s = n(u) ? "" : String.valueOf(u);
if (eq(s, "true"))
return true;
if (eq(s, "false"))
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/HasToolkitMethodModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/HasToolkitMethodModel.java
index dba86b22ad..7d57bd991e 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/HasToolkitMethodModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/HasToolkitMethodModel.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.util.*;
import freemarker.core.*;
@@ -53,6 +55,6 @@ public final class HasToolkitMethodModel implements TemplateMethodModelEx {
if (! (a instanceof TemplateScalarModel s))
throw FtlAttrLists.reject(String.format("%s(name) takes a string; got '%s'.", NAME, a));
var cap = PageCapture.get(Environment.getCurrentEnvironment());
- return cap != null && cap.toolkits().contains(s.getAsString().trim()) ? TemplateBooleanModel.TRUE : TemplateBooleanModel.FALSE;
+ return nn(cap) && cap.toolkits().contains(s.getAsString().trim()) ? TemplateBooleanModel.TRUE : TemplateBooleanModel.FALSE;
}
}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/MainDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/MainDirectiveModel.java
index f77b148a2e..9f15da7e5b 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/MainDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/MainDirectiveModel.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.io.*;
import java.util.*;
@@ -52,11 +54,11 @@ public final class MainDirectiveModel implements TemplateDirectiveModel {
if (p.containsKey("format"))
throw FtlAttrLists.reject("<@main> has no format= attribute.");
FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
- if (body != null)
+ if (nn(body))
throw FtlAttrLists.reject("<@main/> is self-closing; the page body is the main content and takes no nested body.");
var cap = PageCapture.get(env);
- if (cap == null || ! cap.consoleOpen)
+ if (n(cap) || ! cap.consoleOpen)
throw FtlAttrLists.reject(String.format("<@%s> must be nested inside <@console>.", NAME));
cap.markMain();
}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavNode.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavNode.java
index 9d09fe10af..e11035f173 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavNode.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavNode.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.util.*;
import freemarker.template.*;
@@ -66,7 +68,7 @@ public final class NavNode {
for (var c : children)
if (c.id.equals(nodeId))
throw FtlAttrLists.reject(String.format("<@node id='%s'> duplicates a sibling id under '%s'.", nodeId,
- this.id == null ? "(root)" : String.join("/", idPath())));
+ n(this.id) ? "(root)" : String.join("/", idPath())));
var n = new NavNode(this, nodeId, label, hrefOrNull);
children.add(n);
return n;
@@ -85,7 +87,7 @@ public final class NavNode {
for (var c : n.children)
if (c.id.equals(step))
next = c;
- if (next == null)
+ if (n(next))
return Optional.empty();
n = next;
}
@@ -106,7 +108,7 @@ public final class NavNode {
List<String> idPath() {
var out = new LinkedList<String>();
- for (var n = this; n != null && n.id != null; n = n.parent)
+ for (var n = this; nn(n) && nn(n.id); n = n.parent)
out.addFirst(n.id);
return out;
}
@@ -126,7 +128,7 @@ public final class NavNode {
var m = new LinkedHashMap<String,Object>();
m.put("id", c.id);
m.put("label", c.label);
- if (c.href != null)
+ if (nn(c.href))
m.put("href", c.href);
if (! c.children.isEmpty())
m.put("children", c.toList());
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavigationDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavigationDirectiveModel.java
index ff4e590574..e949b0612d 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavigationDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavigationDirectiveModel.java
@@ -56,7 +56,7 @@ public final class NavigationDirectiveModel implements TemplateDirectiveModel {
FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
var cap = PageCapture.get(env);
- if (cap == null || ! cap.consoleOpen)
+ if (n(cap) || ! cap.consoleOpen)
throw FtlAttrLists.reject(String.format("<@%s> must be nested inside <@console>.", NAME));
if (cap.navOpen)
throw FtlAttrLists.reject("<@navigation> cannot be nested inside another <@navigation>.");
@@ -72,7 +72,7 @@ public final class NavigationDirectiveModel implements TemplateDirectiveModel {
cap.navOpen = true;
cap.navCursor.push(cap.navRoot());
try (var sink = new StringWriter()) {
- if (body != null)
+ if (nn(body))
body.render(sink);
} finally {
cap.navCursor.pop();
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NodeDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NodeDirectiveModel.java
index bab4a77a31..8fb7b7140f 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NodeDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NodeDirectiveModel.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.io.*;
import java.util.*;
@@ -81,7 +83,7 @@ public final class NodeDirectiveModel implements TemplateDirectiveModel {
var selected = FtlAttrLists.strictBoolean(p, NAME, "selected", false);
var cap = PageCapture.get(env);
- if (cap == null || ! cap.navOpen)
+ if (n(cap) || ! cap.navOpen)
throw FtlAttrLists.reject("<@node> must be nested inside <@navigation>.");
// visible=false omits the node and its whole subtree; the body is not rendered.
@@ -91,13 +93,13 @@ public final class NodeDirectiveModel implements TemplateDirectiveModel {
var node = cap.navCursor.peek().add(id, label, href.isEmpty() ? null : href);
cap.navCursor.push(node);
try (var sink = new StringWriter()) {
- if (body != null)
+ if (nn(body))
body.render(sink);
} finally {
cap.navCursor.pop();
}
- if (node.children().isEmpty() && node.href() == null)
+ if (node.children().isEmpty() && n(node.href()))
throw FtlAttrLists.reject(String.format("<@node id='%s'> leaf requires href=.", id));
if (selected)
cap.select(node.idPath());
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageCapture.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageCapture.java
index cb0f72616f..78ca675371 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageCapture.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageCapture.java
@@ -180,7 +180,7 @@ public final class PageCapture {
*/
public static PageCapture of(Environment env) {
var c = (PageCapture)env.getCustomState(KEY);
- if (c == null) {
+ if (n(c)) {
c = new PageCapture();
env.setCustomState(KEY, c);
}
@@ -206,7 +206,7 @@ public final class PageCapture {
* @throws IOException On an I/O error.
*/
public static String render(TemplateDirectiveBody body) throws TemplateException, IOException {
- if (body == null)
+ if (n(body))
return "";
var sw = new StringWriter();
body.render(sw);
@@ -224,7 +224,7 @@ public final class PageCapture {
public PageCapture theme(String name) { theme = name; return this; }
/** @param v The {@code <@page tab>} value. @return This object. */
- public PageCapture tab(String v) { tab = v == null ? "" : v; return this; }
+ public PageCapture tab(String v) { tab = n(v) ? "" : v; return this; }
/** @param names Toolkit names from {@code <@page toolkit>}. @return This object. */
public PageCapture toolkit(List<String> names) { toolkits = List.copyOf(names); return this; }
@@ -249,7 +249,7 @@ public final class PageCapture {
* @throws TemplateModelException If a path is already selected (E-6).
*/
public PageCapture select(List<String> idPath) throws TemplateModelException {
- if (selected != null)
+ if (nn(selected))
throw FtlAttrLists.reject(String.format("<@node id='%s'> selected=true but '%s' is already selected.",
idPath.get(idPath.size() - 1), String.join("/", selected)));
selected = List.copyOf(idPath);
@@ -268,7 +268,7 @@ public final class PageCapture {
for (var c : cards)
if (c.id().equals(card.id()))
throw FtlAttrLists.reject(String.format("<@card id='%s'> duplicates an existing card id.", card.id()));
- if (markupOrNull != null) {
+ if (nn(markupOrNull)) {
card.template(card.id());
templates.put(card.id(), markupOrNull);
}
@@ -327,7 +327,7 @@ public final class PageCapture {
/** @return <jk>true</jk> while a {@code <@page>} body is rendering. */
public boolean inPage() { return pageOpen; }
String title() { return title; }
- boolean hasHeaderTitle() { return header.title != null; }
+ boolean hasHeaderTitle() { return nn(header.title); }
//-----------------------------------------------------------------------------------------------------------------
// Lifecycle hooks used by the directives
@@ -367,7 +367,7 @@ public final class PageCapture {
/** §4.3: explicit selection, else tab (E-7 when it does not resolve), else empty. */
void resolveActiveNav() throws TemplateModelException {
- if (selected != null) {
+ if (nn(selected)) {
activeNav = selected;
return;
}
@@ -439,7 +439,7 @@ public final class PageCapture {
var m = new LinkedHashMap<String,Object>();
m.put("contractVersion", CONTRACT_VERSION);
m.put("title", title);
- if (theme != null)
+ if (nn(theme))
m.put("theme", Map.of("name", theme));
var h = headerMap();
if (! h.isEmpty())
@@ -447,7 +447,7 @@ public final class PageCapture {
var f = footerMap();
if (! f.isEmpty())
m.put("footer", f);
- if (navLayout != null)
+ if (nn(navLayout))
m.put("navLayout", navLayout);
m.put("nav", navRoot.toList());
m.put("activeNav", activeNav);
@@ -480,17 +480,17 @@ public final class PageCapture {
private Map<String,Object> headerMap() {
var m = new LinkedHashMap<String,Object>();
- if (header.title != null) m.put("title", header.title);
- if (header.subtitle != null) m.put("subtitle", header.subtitle);
- if (header.logoSrc != null) {
+ if (nn(header.title)) m.put("title", header.title);
+ if (nn(header.subtitle)) m.put("subtitle", header.subtitle);
+ if (nn(header.logoSrc)) {
var l = new LinkedHashMap<String,Object>();
l.put("src", header.logoSrc);
- if (header.logoHref != null) l.put("href", header.logoHref);
- if (header.logoAlt != null) l.put("alt", header.logoAlt);
+ if (nn(header.logoHref)) l.put("href", header.logoHref);
+ if (nn(header.logoAlt)) l.put("alt", header.logoAlt);
m.put("logo", l);
}
if (! header.links.isEmpty()) m.put("links", header.links);
- if (header.userMenu != null) m.put("userMenu", header.userMenu);
+ if (nn(header.userMenu)) m.put("userMenu", header.userMenu);
if (header.chrome) m.put("chrome", true);
if (slots.containsKey("header")) m.put("slots", slots.get("header"));
return m;
@@ -498,7 +498,7 @@ public final class PageCapture {
private Map<String,Object> footerMap() {
var m = new LinkedHashMap<String,Object>();
- if (footer.text != null) m.put("text", footer.text);
+ if (nn(footer.text)) m.put("text", footer.text);
if (! footer.links.isEmpty()) m.put("links", footer.links);
if (slots.containsKey("footer")) m.put("slots", slots.get("footer"));
return m;
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirectiveModel.java
index 3593883a00..8d3122c048 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirectiveModel.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.io.*;
import java.util.*;
@@ -60,7 +62,7 @@ public final class PageDirectiveModel implements TemplateDirectiveModel {
if (p.containsKey("format"))
throw FtlAttrLists.reject("<@page> has no format= attribute.");
FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
- if (body == null)
+ if (n(body))
throw FtlAttrLists.reject("<@page> requires a nested body.");
var cap = PageCapture.of(env);
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TagMethodModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TagMethodModel.java
index 3908a23ab9..cda037d90d 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TagMethodModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TagMethodModel.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.util.*;
import org.apache.juneau.bean.html5.*;
@@ -80,7 +82,7 @@ final class TagMethodModel implements TemplateMethodModelEx {
}
private static String normalize(String argName, String raw) {
- if (raw == null)
+ if (n(raw))
throw new IllegalArgumentException("Tag " + argName + " must not be null.");
var lower = raw.toLowerCase(Locale.ROOT);
if (! lower.matches("^[a-z0-9_-]+$"))
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ThemeDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ThemeDirectiveModel.java
index 7cea5c68f4..3529c3a5f1 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ThemeDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ThemeDirectiveModel.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.io.*;
import java.util.*;
@@ -93,12 +95,12 @@ public final class ThemeDirectiveModel implements TemplateDirectiveModel {
+ String.join(", ", ConsoleChromeMixin.BUILTIN_THEME_NAMES) + ".");
var cap = PageCapture.get(env);
- if (cap == null || ! cap.consoleOpen)
+ if (n(cap) || ! cap.consoleOpen)
throw FtlAttrLists.reject(String.format(
"<@theme name='%s'> must be nested inside <@console>; the legacy pageThemeCss path was removed in 10.0.0.", name));
var req = FreemarkerRenderScope.request();
- if (req == null)
+ if (n(req))
throw FtlAttrLists.reject("<@theme> needs FreemarkerRenderScope.request() (renderer wrap).");
// Capture any nested <@token> overrides against the stock-palette seed. The context is cleared afterward so a
@@ -110,7 +112,7 @@ public final class ThemeDirectiveModel implements TemplateDirectiveModel {
var ctx = new ThemeBuildContext(name, themeBuilder);
cap.themeBuild = ctx;
try (var sink = new StringWriter()) {
- if (body != null)
+ if (nn(body))
body.render(sink);
} finally {
cap.themeBuild = null;
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TokenDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TokenDirectiveModel.java
index 466b549c10..fa70865e41 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TokenDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TokenDirectiveModel.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.io.*;
import java.util.*;
@@ -85,8 +87,8 @@ public final class TokenDirectiveModel implements TemplateDirectiveModel {
FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
var cap = PageCapture.get(env);
- var ctx = cap == null ? null : cap.themeBuild;
- if (ctx == null)
+ var ctx = n(cap) ? null : cap.themeBuild;
+ if (n(ctx))
throw FtlAttrLists.reject("<@token> must be nested inside <@theme>.");
var name = FtlAttrLists.scalar(p, "name");
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPack.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPack.java
index d560c67a97..e316c1cb99 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPack.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPack.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
import java.util.*;
/**
@@ -78,7 +80,7 @@ public final class ToolkitPack {
* @return A new builder.
*/
public static Builder create(String name) {
- if (name == null || name.isBlank())
+ if (ib(name))
throw new IllegalArgumentException("Toolkit pack name must not be blank.");
return new Builder(name.trim());
}
@@ -185,7 +187,7 @@ public final class ToolkitPack {
* @param value The kind.
* @return This object.
*/
- public Builder kind(Kind value) { kind = Objects.requireNonNull(value, "kind"); return this; }
+ public Builder kind(Kind value) { kind = rnn(value); return this; }
/**
* Sets the asset-URL resolver. Defaults to {@link ToolkitPackRegistry#VIEWS_RESOLVER}.
@@ -193,7 +195,7 @@ public final class ToolkitPack {
* @param value The resolver.
* @return This object.
*/
- public Builder resolver(ToolkitPackRegistry.AssetUrlResolver value) { resolver = Objects.requireNonNull(value, "resolver"); return this; }
+ public Builder resolver(ToolkitPackRegistry.AssetUrlResolver value) { resolver = rnn(value); return this; }
/**
* Builds the pack.
@@ -202,7 +204,7 @@ public final class ToolkitPack {
* @throws IllegalArgumentException If no kind was set.
*/
public ToolkitPack build() {
- if (kind == null)
+ if (n(kind))
throw new IllegalArgumentException(String.format("Toolkit pack '%s' needs kind(VENDOR) or kind(RUNTIME).", name));
return new ToolkitPack(this);
}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java
index e4f1b169f4..61910857bd 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java
@@ -16,6 +16,7 @@
*/
package org.apache.juneau.rest.server.view.freemarker.console;
+import static org.apache.juneau.commons.utils.Shorts.*;
import static org.apache.juneau.rest.server.view.freemarker.console.ToolkitPack.Kind.*;
import java.util.*;
@@ -227,7 +228,7 @@ public final class ToolkitPackRegistry {
for (var pack : packs.values()) {
for (var dep : pack.dependsOn()) {
var target = packs.get(dep);
- if (target == null)
+ if (n(target))
throw new IllegalArgumentException(String.format("Unknown toolkit pack '%s' (dependsOn of pack '%s').", dep, pack.name()));
if (pack.kind() == VENDOR && target.kind() == RUNTIME)
throw new IllegalArgumentException(String.format("VENDOR toolkit pack '%s' cannot depend on RUNTIME pack '%s'.", pack.name(), dep));
@@ -278,9 +279,9 @@ public final class ToolkitPackRegistry {
* @throws IllegalStateException If the request is missing or a resolver fails (e.g. a WebJar isn't on the classpath).
*/
public Resolved resolve(List<String> names, RestRequest req, List<String> pageUrls, String template) {
- if (names == null || names.isEmpty())
+ if (n(names) || names.isEmpty())
return Resolved.EMPTY;
- if (req == null)
+ if (n(req))
throw new IllegalStateException("<@page toolkit> needs FreemarkerRenderScope.request() (renderer wrap).");
List<ToolkitPack> order;
Set<String> skip;
@@ -331,7 +332,7 @@ public final class ToolkitPackRegistry {
throw new IllegalStateException("Pack '" + pack.name() + "' " + e.getMessage(), e);
}
var dup = pageFiles.get(fileName(url));
- if (dup != null) {
+ if (nn(dup)) {
LOG.warning(String.format("Template '%s': skipped pack '%s' asset %s; the page already loads %s.", template, pack.name(), url, dup));
continue;
}
@@ -357,7 +358,7 @@ public final class ToolkitPackRegistry {
throw new IllegalArgumentException("Toolkit pack cycle: " + String.join(" → ", cycle) + ".");
}
var pack = packs.get(name);
- if (pack == null)
+ if (n(pack))
throw new IllegalArgumentException(String.format("Unknown toolkit pack '%s' (%s).", name, source));
path.add(name);
for (var dep : pack.dependsOn())
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirective_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirective_Test.java
index 5f4d6b21ed..f0fffe0eac 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirective_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirective_Test.java
@@ -72,6 +72,12 @@ class CardDirective_Test extends TestBase {
public View cardDatatablesSearch() { return FreemarkerView.of("admin/page-card-datatables-search.ftlh"); }
@RestGet(path="/card-datatables-badcustom")
public View cardDatatablesBadCustom() { return FreemarkerView.of("admin/page-card-datatables-badcustom.ftlh"); }
+ @RestGet(path="/cards-class")
+ public View cardsClass() { return FreemarkerView.of("admin/page-cards-class.ftlh"); }
+ @RestGet(path="/cards-class-bad")
+ public View cardsClassBad() { return FreemarkerView.of("admin/page-cards-class-bad.ftlh"); }
+ @RestGet(path="/cards-class-digit")
+ public View cardsClassDigit() { return FreemarkerView.of("admin/page-cards-class-digit.ftlh"); }
@RestGet(path="/card-html-template")
public View cardHtmlTemplate() { return FreemarkerView.of("admin/page-card-html-template.ftlh"); }
}
@@ -206,4 +212,29 @@ class CardDirective_Test extends TestBase {
for (var path : new String[] {"/cards-html", "/card-datatables", "/card-html-template"})
assertFalse(get(path).contains("juneau-card-sidecar"), path);
}
+
+ @Test void b15_classAttr_singleClass_isInContract() throws Exception {
+ var body = get("/cards-class");
+ assertEquals("ssc-skills", card(body, "one").getString("class"));
+ assertEquals("ssc-scripts", card(body, "dt").getString("class"));
+ }
+
+ @Test void b16_classAttr_multipleClasses_areNormalizedToSingleSpaces() throws Exception {
+ assertEquals("ssc-skills x_1 -y", card(get("/cards-class"), "many").getString("class"));
+ }
+
+ @Test void b17_classAttr_absent_emitsNoClassKey() throws Exception {
+ assertFalse(card(get("/cards-class"), "none").containsKey("class"));
+ }
+
+ @Test void b18_classAttr_invalidToken_failsTheRender() throws Exception {
+ for (var path : new String[] {"/cards-class-bad", "/cards-class-digit"})
+ try (var c = MockRestClient.buildLax(Host.class);
+ var rsp = c.get(path).run()) {
+ rsp.assertStatus(500);
+ var body = rsp.getContent().asString();
+ assertTrue(body.contains("<@card id='bad'> class='"), () -> body);
+ assertTrue(body.contains("must be a space-separated list of CSS class names"), () -> body);
+ }
+ }
}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_SelfLint_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_SelfLint_Test.java
index c90757f7a4..785862fb5f 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_SelfLint_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_SelfLint_Test.java
@@ -49,9 +49,7 @@ class ConsoleTemplateValidator_SelfLint_Test extends TestBase {
"c1/bool-chrome-empty.ftlh", // NodeDirective_StrictBoolean_Test.a04_rejected_E5 expects E-5 strict-boolean
"c1/rv-card-src.ftlh", // CardDirective_RunView_Test expects the src= rejection; card-src-and-body also flags it
"c1/co-card-src.ftlh", // CardDirective_ConsoleOutput_Test expects the src= rejection; card-src-and-body also flags it
- "c1/cv-omit.ftlh", // ViewContractVersion_Test renders it through the pre-built SLOT_META escape hatch; inline-slot-meta flags it (removed with the hatch)
- "c1/cv-pinned-ok.ftlh", // ViewContractVersion_Test renders it through the pre-built SLOT_META escape hatch; inline-slot-meta flags it (removed with the hatch)
- "c1/cv-pinned-stale.ftlh", // ViewContractVersion_Test.b03_render_mismatched_failsInDevMode expects the stale-pin failure (and inline-slot-meta)
+ "c1/cv-pinned-stale.ftlh", // ViewContractVersion_Test.b03_render_prebuiltSlotMeta_failsWithE28 expects E-28
"c1/rv-card-template.ftlh" // CardDirective_RunView_Test rejects template= on a run-view card
);
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_Test.java
index 820e1d9285..730cdff487 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_Test.java
@@ -163,7 +163,7 @@ class ConsoleTemplateValidator_Test extends TestBase {
}
//-----------------------------------------------------------------------------------------------------------------
- // card-src-and-body, inline-slot-meta, card-reserved-key (C2)
+ // card-src-and-body, card-reserved-key (C2)
//-----------------------------------------------------------------------------------------------------------------
@Test void cb01_cardSrcAndBody() {
@@ -176,20 +176,10 @@ class ConsoleTemplateValidator_Test extends TestBase {
assertClean("<@page><@card id=\"t\" type=\"datatables\" src=\"/rest/t\"/></...@page>");
}
- @Test void cb03_inlineSlotMeta() {
- assertOne("<@page><@card id=\"t\" type=\"datatables\">{layout:'wide',view:{}}</@...@page>",
- 1, 8, "inline-slot-meta",
- "<@card id='t'> body is a pre-built SLOT_META (has a 'view' object); author the table catalog in author shape instead. This becomes an error at GC-1.");
- }
-
- @Test void cb03b_inlineSlotMeta_staleVersionAlsoReportsViewContractVersion() {
- // C1's view-contract-version rule stays (Auto-decision 4); both rules fire on a stale pinned escape hatch.
+ @Test void cb03_prebuiltSlotMeta_reportsOnlyViewContractVersionAfterGc1() {
+ // inline-slot-meta is gone at GC-1 (E-28 at render); C1's view-contract-version rule stays (Auto-decision 4).
var f = lint("<@page><@card id=\"t\" type=\"datatables\">{view:{contractVersion:'0',id:'t'}}</@...@page>");
- assertEquals(List.of("inline-slot-meta", "view-contract-version"), f.stream().map(x -> x.rule()).sorted().toList());
- }
-
- @Test void cb04_inlineSlotMeta_onlyForDatatables() {
- assertClean("<@page><@card id=\"t\" type=\"kpi\">{contractVersion:'1',view:{}}</@...@page>");
+ assertEquals(List.of("view-contract-version"), f.stream().map(x -> x.rule()).toList());
}
@Test void cb05_cardReservedKey() {
@@ -209,6 +199,25 @@ class ConsoleTemplateValidator_Test extends TestBase {
assertClean("<@page><@card id=\"t\">{value:1}</@...@page>");
}
+ @Test void cb09_classAttr_isAcceptedOnCard() {
+ assertClean("<@page><@card id=\"t\" class=\"ssc-skills x\"><p>...@page>");
+ }
+
+ @Test void cb10_escapedQuoteInSingleQuotedString_isNotAReservedKey() {
+ assertClean("<@page><@card id=\"t\" type=\"kpi\">{ label: 'item\\'s', note: 'id: 1, type: 2', value: 1 }</@...@page>");
+ }
+
+ @Test void cb11_escapedQuoteInDoubleQuotedString_isNotAReservedKey() {
+ assertClean("<@page><@card id=\"t\" type=\"kpi\">{ \"say\": \"say \\\"hi\\\" id: x\", value: 1 }</@...@page>");
+ }
+
+ @Test void cb12_reservedKeyAfterEscapedQuote_isStillCaught() {
+ assertOne("<@page><@card id=\"t\" type=\"kpi\">{ label: 'item\\'s', src: 'x', value: 1 }</@...@page>",
+ 1, 8, "card-reserved-key", "<@card id='t'> body key 'src' is reserved; set it as an attribute.");
+ assertOne("<@page><@card id=\"t\" type=\"kpi\">{ label: \"say \\\"hi\\\"\", id: 'x' }</@...@page>",
+ 1, 8, "card-reserved-key", "<@card id='t'> body key 'id' is reserved; set it as an attribute.");
+ }
+
//-----------------------------------------------------------------------------------------------------------------
// removed-card-type, removed-global
//-----------------------------------------------------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType_Test.java
index e808f56d61..3049e43913 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType_Test.java
@@ -60,10 +60,15 @@ class DatatablesCardType_Test extends TestBase {
assertEquals("<@card id='t'> type='datatables' requires src= or a body with 'columns' and exactly one of 'dataUrl' or 'rows'.", ex.getMessage());
}
- @Test void preBuiltSlotMetaEscapeHatch_passesThroughUnchanged() {
- var card = h.toFragment(src("t", "{contractVersion:'1', view:{id:'t', dataUrl:'/x', columns:[]}}"));
- var table = (JsonMap) card.get("table");
- assertEquals("1", table.get("contractVersion"));
+ @Test void preBuiltSlotMeta_throwsE28() {
+ var expected = "<@card id='t'> type='datatables' body is a pre-built SLOT_META envelope; "
+ + "remove 'contractVersion', 'layout' and 'view' and author the catalog form.";
+ var ex = assertThrows(IllegalArgumentException.class,
+ () -> h.toFragment(src("t", "{contractVersion:'1', view:{id:'t', dataUrl:'/x', columns:[]}}")));
+ assertEquals(expected, ex.getMessage());
+ ex = assertThrows(IllegalArgumentException.class,
+ () -> h.toFragment(src("t", "{view:{id:'t', dataUrl:'/x', columns:[]}}")));
+ assertEquals(expected, ex.getMessage());
}
@Test void columnMissingKeyAndData_throwsE29() {
@@ -131,12 +136,6 @@ class DatatablesCardType_Test extends TestBase {
assertEquals("a", table.getList("columns").getMap(0).get("key"));
}
- @Test void preBuiltEnvelope_withoutSlotContractVersion_passesThrough() {
- var card = h.toFragment(src("t", "{view:{id:'t', dataUrl:'/x', columns:[]}}"));
- assertNull(((JsonMap) card.get("table")).get("contractVersion"));
- assertNotNull(((JsonMap) card.get("table")).get("view"));
- }
-
@Test void inlineRows_only_ok() {
var table = (JsonMap) h.toFragment(src("t", "{rows:[{a:1}], columns:[{key:'a'}]}")).get("table");
assertEquals(1, table.getList("rows").size());
@@ -174,12 +173,6 @@ class DatatablesCardType_Test extends TestBase {
assertDoesNotThrow(() -> h.toFragment(src("t", "{rows:[], dataMode:'client', columns:[{key:'a'}]}")));
}
- @Test void prebuiltEnvelope_withRowsAndDataUrl_passesThroughUntouched() {
- var table = (JsonMap) h.toFragment(src("t", "{contractVersion:'1', view:{id:'t', dataUrl:'/d', columns:[]}, rows:[{a:1}]}")).get("table");
- assertNotNull(table.get("view"));
- assertEquals(1, table.getList("rows").size());
- }
-
@Test void pageKey_isHoistedToFragment() {
var frag = h.toFragment(src("t", "{dataUrl:'/x', columns:[{key:'a'}], page:{size:25}}"));
assertNotNull(frag.get("page"));
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ModuleGraph_ImportScan_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ModuleGraph_ImportScan_Test.java
index 71acf7c325..4c5de2d967 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ModuleGraph_ImportScan_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ModuleGraph_ImportScan_Test.java
@@ -28,8 +28,8 @@ import org.junit.jupiter.api.*;
/**
* Phase 5 module-graph re-check (now with real code): no class under {@code console-ui-freemarker}'s MAIN source
* imports {@code org.apache.juneau.rest.server.datatables.*}. A chrome-only consumer of this module never drags
- * {@code -datatables} onto the classpath (the {@code console-ui-freemarker-datatables} module is the only one
- * allowed to import that package — see Phase 7's own module-graph re-check).
+ * {@code -datatables} onto the classpath (no console module imports that package; {@code datatables} cards reach
+ * DataTables through the {@code datatables-glue} toolkit pack).
*/
class ModuleGraph_ImportScan_Test extends TestBase {
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirective_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirective_Test.java
index fc66532caa..ea5765046c 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirective_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirective_Test.java
@@ -126,7 +126,7 @@ class PageDirective_Test extends TestBase {
}
@Test void freeze_demoDatatableMacro_isNotTypeDatatables() throws Exception {
- // The demo <@datatable> macro lives in the datatables module; this module must NOT define a shared
+ // The old <@datatable> macro module is gone; nothing may re-introduce a shared variable named "datatable".
// variable named "datatable". type="datatables" on <@card> is the framework surface, not a directive.
var cfg = ConsoleFreemarkerMixin.create().basePath("/templates/")
.chromeTemplate("admin/console-chrome-bare.ftlh").build()
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ViewContractVersion_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ViewContractVersion_Test.java
index ac54bba1d8..8278ef1136 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ViewContractVersion_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ViewContractVersion_Test.java
@@ -16,14 +16,10 @@ import static org.apache.juneau.test.bct.BctAssertions.*;
import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
-import java.util.logging.*;
import org.apache.juneau.*;
import org.apache.juneau.commons.inject.*;
import org.apache.juneau.http.Path;
-import org.apache.juneau.marshall.collections.*;
-import org.apache.juneau.marshall.json5.*;
-import org.apache.juneau.marshall.parser.*;
import org.apache.juneau.rest.mock.classic.*;
import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.servlet.*;
@@ -33,8 +29,8 @@ import org.apache.juneau.rest.server.views.*;
import org.junit.jupiter.api.*;
/**
- * {@code view.contractVersion} auto-injection: omitted → injected, matching → silent, mismatched
- * → WARNING naming the template and both versions, plus a loud failure in dev mode and a static validator finding.
+ * A pre-built SLOT_META body fails at render time with E-28; the static {@code view-contract-version} validator rule
+ * still flags a stale pinned version.
*
* @since 10.0.0
*/
@@ -45,73 +41,6 @@ class ViewContractVersion_Test extends TestBase {
private static final String RUNTIME = ViewsMixin.CONTRACT_VERSION;
- /** Captures WARNING-and-above records emitted by {@link DatatablesCardType}. */
- private static final class Capture extends Handler {
- final List<String> messages = new ArrayList<>();
- @Override public void publish(LogRecord r) { messages.add(r.getLevel() + ": " + r.getMessage()); }
- @Override public void flush() { /* no-op */ }
- @Override public void close() { /* no-op */ }
- }
-
- private static List<String> warningsFrom(Runnable r) {
- var log = Logger.getLogger(DatatablesCardType.class.getName());
- var cap = new Capture();
- log.addHandler(cap);
- try {
- r.run();
- } finally {
- log.removeHandler(cap);
- }
- return cap.messages;
- }
-
- private static JsonMap parse(String json5) {
- try {
- return Json5Parser.DEFAULT.read(json5, JsonMap.class);
- } catch (ParseException e) {
- throw new IllegalArgumentException(e);
- }
- }
-
- private static JsonMap catalog(String versionClause) {
- return parse("{contractVersion:'1', layout:'wide', view:{" + versionClause + "id:'t', dataUrl:'/d', columns:[]}}");
- }
-
- @Test void a01_omitted_isInjected() {
- var slot = catalog("");
- var warnings = warningsFrom(() -> DatatablesCardType.reconcileViewVersion("t", slot, "p.ftlh", true));
- assertEquals(RUNTIME, slot.getMap("view").getString("contractVersion"));
- assertTrue(warnings.isEmpty(), warnings::toString);
- }
-
- @Test void a02_omittedSlotVersion_isAlsoDefaulted() {
- var slot = parse("{view:{id:'t', dataUrl:'/d', columns:[]}}");
- DatatablesCardType.reconcileViewVersion("t", slot, "p.ftlh", false);
- assertBean(slot, "contractVersion,view{contractVersion}", "1,{" + RUNTIME + "}");
- }
-
- @Test void a03_matching_noWarning() {
- var slot = catalog("contractVersion:'" + RUNTIME + "',");
- var warnings = warningsFrom(() -> DatatablesCardType.reconcileViewVersion("t", slot, "p.ftlh", true));
- assertTrue(warnings.isEmpty(), warnings::toString);
- assertEquals(RUNTIME, slot.getMap("view").getString("contractVersion"));
- }
-
- @Test void a04_mismatched_warnsNamingTemplateAndBothVersions_notDev() {
- var slot = catalog("contractVersion:'0',");
- var warnings = warningsFrom(() -> DatatablesCardType.reconcileViewVersion("t", slot, "p.ftlh", false));
- assertEquals(1, warnings.size(), warnings::toString);
- assertTrue(warnings.get(0).startsWith("WARNING: "), warnings::toString);
- assertContainsAll(warnings.get(0), "'p.ftlh'", "card 't'", "is '0'", "is '" + RUNTIME + "'");
- assertEquals("0", slot.getMap("view").getString("contractVersion"), "an explicit pin is left alone");
- }
-
- @Test void a05_mismatched_failsLoudlyInDevMode() {
- var slot = catalog("contractVersion:'0',");
- var ex = assertThrows(IllegalArgumentException.class, () -> DatatablesCardType.reconcileViewVersion("t", slot, "p.ftlh", true));
- assertContainsAll(ex.getMessage(), "'p.ftlh'", "is '0'", "is '" + RUNTIME + "'");
- }
-
//-----------------------------------------------------------------------------------------------------------------
// End to end through <@card type="datatables">
//-----------------------------------------------------------------------------------------------------------------
@@ -128,23 +57,10 @@ class ViewContractVersion_Test extends TestBase {
}
}
- @Test void b01_render_omitted_injectsRuntimeVersion() throws Exception {
- try (var c = MockRestClient.buildLax(DevHost.class); var rsp = c.get("/t/cv-omit").run()) {
- rsp.assertStatus(200);
- assertTrue(rsp.getContent().asString().contains("\"contractVersion\":\"" + RUNTIME + "\""));
- }
- }
-
- @Test void b02_render_matching_ok() throws Exception {
- try (var c = MockRestClient.buildLax(DevHost.class); var rsp = c.get("/t/cv-pinned-ok").run()) {
- rsp.assertStatus(200);
- }
- }
-
- @Test void b03_render_mismatched_failsInDevMode() throws Exception {
+ @Test void b03_render_prebuiltSlotMeta_failsWithE28() throws Exception {
try (var c = MockRestClient.buildLax(DevHost.class); var rsp = c.get("/t/cv-pinned-stale").run()) {
rsp.assertStatus(500);
- assertContainsAll(rsp.getContent().asString(), "c1/cv-pinned-stale.ftlh", "is '4'", "is '" + RUNTIME + "'");
+ assertContainsAll(rsp.getContent().asString(), "c1/cv-pinned-stale.ftlh", "body is a pre-built SLOT_META envelope");
}
}
@@ -153,7 +69,7 @@ class ViewContractVersion_Test extends TestBase {
//-----------------------------------------------------------------------------------------------------------------
private static List<ConsoleTemplateValidator.Finding> lint(String source) {
- // Only the version rule is under test; the fixtures' pre-built view objects also trip the unrelated inline-slot-meta rule.
+ // Only the version rule is under test.
return ConsoleTemplateValidator.create().validateSource("t.ftlh", source).stream().filter(x -> "view-contract-version".equals(x.rule())).toList();
}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/chrome.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-bad.ftlh
similarity index 87%
rename from juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/chrome.ftlh
rename to juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-bad.ftlh
index f5ba7e0acd..ca36e934aa 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/chrome.ftlh
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-bad.ftlh
@@ -13,9 +13,6 @@
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.
---><@console>
-<@navigation>
-<@node id="home" label="Home" href="/home"/>
-</...@navigation>
-<@main/>
-</...@console>
+--><@page>
+<@card id="bad" class="ok a"b"><p>...@card>
+</...@page>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/page-datatable.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-digit.ftlh
similarity index 82%
rename from juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/page-datatable.ftlh
rename to juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-digit.ftlh
index 7a090b4023..06ee1821ea 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/page-datatable.ftlh
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-digit.ftlh
@@ -13,5 +13,6 @@
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.
---><#include "/org/apache/juneau/console/datatables/datatable.ftlh">
-<@page toolkit="views"><@datatable id="releases" rows=releases rowType=rowTypeName/></...@page>
+--><@page>
+<@card id="bad" class="9lives"><p>...@card>
+</...@page>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-omit.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class.ftlh
similarity index 73%
rename from juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-omit.ftlh
rename to juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class.ftlh
index accdd7b9eb..930b6366ce 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-omit.ftlh
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class.ftlh
@@ -13,17 +13,9 @@
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.
--->
-<@page>
-<@card id="t" type="datatables">
-{
- layout: 'wide',
- view: {
-
- id: 't',
- dataUrl: '/d',
- columns: [ { data: 'a', title: 'A' } ]
- }
-}
-</...@card>
+--><@page>
+<@card id="one" class="ssc-skills"><p>...@card>
+<@card id="many" class=" ssc-skills x_1 -y "><p>...@card>
+<@card id="none"><p>...@card>
+<@card id="dt" type="datatables" class="ssc-scripts">{ dataUrl: '/rest/t', columns: [ { key: 'name', label: 'Name' } ] }</...@card>
</...@page>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-pinned-ok.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-pinned-ok.ftlh
deleted file mode 100644
index 001e6983d9..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-pinned-ok.ftlh
+++ /dev/null
@@ -1,29 +0,0 @@
-<#--
- 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.
--->
-<@page>
-<@card id="t" type="datatables">
-{
- layout: 'wide',
- view: {
- contractVersion: '5',
- id: 't',
- dataUrl: '/d',
- columns: [ { data: 'a', title: 'A' } ]
- }
-}
-</...@card>
-</...@page>
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
index 2dd8eb6d03..4555a66111 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
@@ -179,6 +179,15 @@ public class ConsoleChromeMixin {
/** Classpath location of the console shell script. */
public static final String CONSOLE_JS_RESOURCE = "/org/apache/juneau/console/juneau-console.js";
+ /** Context-root-absolute path of the count-badge script. */
+ public static final String BADGES_JS_PATH = "/juneau-console/juneau-badges.js";
+
+ /** {@link #BADGES_JS_PATH} minus the {@code /juneau-console} prefix. */
+ public static final String BADGES_JS_PATH_UNPREFIXED = "/juneau-badges.js";
+
+ /** Classpath location of the count-badge script. */
+ public static final String BADGES_JS_RESOURCE = "/org/apache/juneau/console/juneau-badges.js";
+
/** Context-root-absolute path of the page-contract schema. */
public static final String SCHEMA_PATH = "/juneau-console/juneau-page.schema.json";
@@ -354,6 +363,21 @@ public class ConsoleChromeMixin {
return ASSET_CACHE.serve(CONSOLE_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL);
}
+ /**
+ * [GET /juneau-console/juneau-badges.js] — serve the count-badge script.
+ *
+ * @return The script.
+ */
+ @RestGet(
+ path={BADGES_JS_PATH, BADGES_JS_PATH_UNPREFIXED},
+ summary="Console count-badge script",
+ description="Polls and renders the header.badges entries of the #juneau-page contract.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource getBadgesJs() {
+ return ASSET_CACHE.serve(BADGES_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL);
+ }
+
/**
* [GET /juneau-console/juneau-page.schema.json] — serve the page-contract schema.
*
@@ -418,6 +442,16 @@ public class ConsoleChromeMixin {
return consoleMountUrl(req, CONSOLE_JS_PATH, CONSOLE_JS_RESOURCE);
}
+ /**
+ * Context-root-absolute, cache-busted URL of the count-badge script.
+ *
+ * @param req The current request.
+ * @return The URL.
+ */
+ public static String badgesJsUrl(RestRequest req) {
+ return consoleMountUrl(req, BADGES_JS_PATH, BADGES_JS_RESOURCE);
+ }
+
/**
* Context-root-absolute URL for an asset on the standalone {@code /juneau-console/*} mount.
*
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
index 474e341eef..7d5046d221 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
@@ -240,6 +240,59 @@ a:hover { text-decoration: underline; }
border: 1px solid var(--jc-tag-neutral-border);
}
+/* Count badges (header.badges[]): inline pills fed by a polled endpoint; tones reuse the StatusTone tokens above. */
+.jc-header-badges {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.jc-count-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 10px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.4;
+ cursor: pointer;
+ background-color: var(--jc-tag-amber-bg);
+ color: var(--jc-tag-amber-text);
+ border: 1px solid var(--jc-tag-amber-border);
+}
+.jc-count-badge[hidden] { display: none; }
+.jc-count-badge[data-juneau-badge-tone="info"] { background-color: var(--jc-tag-blue-bg); color: var(--jc-tag-blue-text); border-color: var(--jc-tag-blue-border); }
+.jc-count-badge[data-juneau-badge-tone="error"] { background-color: var(--jc-tag-red-bg); color: var(--jc-tag-red-text); border-color: var(--jc-tag-red-border); }
+.jc-count-badge--stale { background-color: var(--jc-tag-neutral-bg); color: var(--jc-tag-neutral-text); border-color: var(--jc-tag-neutral-border); }
+.jc-count-badge--pulse { animation: jc-count-badge-pulse 0.8s ease-out 1; }
+
+@keyframes jc-count-badge-pulse {
+ 0% { transform: scale(1); }
+ 40% { transform: scale(1.15); }
+ 100% { transform: scale(1); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .jc-count-badge--pulse { animation: none; }
+}
+
+.jc-count-badge-popover {
+ position: absolute;
+ z-index: 1000;
+ max-width: 320px;
+ padding: 6px 0;
+ background-color: var(--jc-surface, #fff);
+ color: inherit;
+ border: 1px solid var(--jc-tag-neutral-border);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
+ font-size: 13px;
+}
+.jc-count-badge-popover-item,
+.jc-count-badge-popover-more { display: block; padding: 4px 12px; color: inherit; text-decoration: none; }
+.jc-count-badge-popover-item--mine { font-weight: 600; }
+.jc-count-badge-popover-more { opacity: 0.7; }
+
/* A dot badge (no count) collapses to a small circle in the same corner. */
.jc-badge.jc-badge-dot {
min-width: 8px;
@@ -1329,6 +1382,15 @@ body::after {
background-color: var(--jc-btn-primary-bg-hover);
}
+.juneau-view-dialog-confirm.juneau-view-dialog-confirm-danger {
+ background-color: var(--jc-danger);
+ color: var(--jc-on-btn-primary);
+ border-color: var(--jc-danger);
+}
+.juneau-view-dialog-confirm.juneau-view-dialog-confirm-danger:hover {
+ filter: brightness(0.92);
+}
+
.juneau-view-dialog-form-row input:not([type="checkbox"]):not([type="hidden"]),
.juneau-view-dialog-form-row textarea,
.juneau-view-dialog-form-row select {
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-badges.js b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-badges.js
new file mode 100644
index 0000000000..3a6c1a9d4a
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-badges.js
@@ -0,0 +1,396 @@
+/*
+ * 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.
+ */
+
+/**
+ * Juneau console count badges. Mounts the `header.badges[]` entries of a page contract (see
+ * juneau-page.schema.json): each badge polls a same-origin JSON endpoint returning {total, mine?, items?[]},
+ * renders a labelled count, hides itself at zero, and lists the first items in a popover.
+ *
+ * Badges are presentation only: the endpoint must still authorize the caller. A 401/403 removes the badge and
+ * stops polling for it; any other failure marks it stale and retries with exponential backoff.
+ *
+ * @example
+ * JuneauConsoleBadges.mount([{ id: 'pending', src: '/rest/changes/pending', href: '/ui/changes', refreshMs: 60000 }]);
+ *
+ * Error codes: E-JS-65 (fetch failed, or 401/403), E-JS-66 (response has no numeric `total`).
+ */
+(function () {
+ "use strict";
+
+ const NS = window.JuneauConsoleBadges = window.JuneauConsoleBadges || {};
+
+ const MIN_REFRESH_MS = 5000;
+ const DEFAULT_REFRESH_MS = 60000;
+ const DEFAULT_TOOLTIP_MAX = 10;
+ const MAX_BACKOFF_MS = 5 * 60 * 1000;
+ const KNOWN_OPS = ["eq", "ne", "present", "absent", "in", "contains"];
+ // The contract's `danger` tone paints with the shared `error` status tone; the other two names match.
+ const TONE_TOKEN = { info: "info", warning: "warning", danger: "error" };
+
+ function fmt(msg, ...args) {
+ let i = 0;
+ return msg.replace(/%s/g, () => String(args[i++]));
+ }
+
+ function logError(code, msg, ...args) {
+ console.error("[juneau-badges] " + code + ": " + fmt(msg, ...args));
+ }
+
+ let mounted = {}; // badge id -> state
+
+ /**
+ * Supplies the page context a badge is scoped and gated by: `{activeNav: [ids], tableTab: {cardId: tabId},
+ * facts: {...}}`. The default reads the mounted JuneauConsole contract and the `?state=` tab.
+ */
+ function defaultContext() {
+ const jc = window.JuneauConsole;
+ const contract = jc && typeof jc.contract === "function" ? jc.contract() : null;
+ const ctx = { activeNav: (contract && contract.activeNav) || [], facts: (contract && contract.facts) || {}, tableTab: {} };
+ const urlState = window.JuneauViews && window.JuneauViews.urlState;
+ const loc = window.location;
+ if (urlState && typeof urlState.decode === "function" && loc && loc.search) {
+ const raw = new window.URLSearchParams(loc.search).get("state");
+ const tab = raw ? urlState.decode(raw).tab : null;
+ if (tab) ctx.tableTab = new Proxy({}, { get: () => tab });
+ }
+ return ctx;
+ }
+
+ let contextProvider = defaultContext;
+ NS.setContextProvider = function (fn) { contextProvider = typeof fn === "function" ? fn : defaultContext; };
+
+ /**
+ * Mounts badge definitions (the `header.badges[]` wire shape).
+ * @param {Array<Object>} defs
+ */
+ NS.mount = function (defs) {
+ for (const def of defs || []) mountOne(def);
+ };
+
+ /** Unmounts every badge and stops polling. */
+ NS.unmountAll = function () {
+ for (const id of Object.keys(mounted)) {
+ const st = mounted[id];
+ if (st.timer) clearTimeout(st.timer);
+ closeTooltip(st);
+ if (st.el && st.el.parentNode) st.el.parentNode.removeChild(st.el);
+ }
+ mounted = {};
+ };
+
+ // ---- visibleWhen (same semantics as the shell's card visibility: a missing fact fails closed except `absent`) ----
+
+ function factAt(facts, path) {
+ let cur = facts;
+ for (const part of String(path || "").split(".")) {
+ if (cur == null || typeof cur !== "object" || !(part in cur)) return { missing: true };
+ cur = cur[part];
+ }
+ return { value: cur };
+ }
+
+ function ruleMatches(rule, facts) {
+ const f = factAt(facts, rule && rule.field);
+ const blank = f.missing || f.value == null || (typeof f.value === "string" && f.value.trim() === "");
+ switch (rule && rule.op) {
+ case "eq": return !f.missing && f.value === rule.value;
+ case "ne": return !f.missing && f.value !== rule.value;
+ case "present": return !blank;
+ case "absent": return blank;
+ case "in": return !f.missing && Array.isArray(rule.value) && rule.value.includes(f.value);
+ case "contains": return !f.missing && Array.isArray(f.value) && f.value.includes(rule.value);
+ default:
+ console.error("[juneau-badges] " + fmt("visibleWhen rule on field '%s' has unknown op '%s' (known: '%s')",
+ rule && rule.field, rule && rule.op, KNOWN_OPS.join(", ")));
+ return false;
+ }
+ }
+
+ function visible(def) {
+ if (!def.visibleWhen) return true;
+ const rules = Array.isArray(def.visibleWhen) ? def.visibleWhen : [def.visibleWhen];
+ const facts = (contextProvider() || {}).facts || {};
+ return rules.every(r => ruleMatches(r, facts));
+ }
+
+ // ---- polling ----
+
+ function mountOne(def) {
+ if (!def || !def.id || mounted[def.id]) return;
+ if (!visible(def)) return;
+ const st = { def: def, seenIds: null, backoffMs: 0, timer: null, el: null, popover: null, lastTotal: null, lastBody: null };
+ mounted[def.id] = st;
+ poll(st);
+ }
+
+ function resolveScope(def) {
+ if (!def.scope) return {};
+ const ctx = contextProvider() || {};
+ const by = def.scope.by || "nav";
+ const key = by === "view"
+ ? (ctx.tableTab && ctx.tableTab[def.table])
+ : (ctx.activeNav && ctx.activeNav[ctx.activeNav.length - 1]);
+ if (def.scope.values) {
+ const vals = key != null ? def.scope.values[key] : null;
+ if (!vals || !vals.length) return null; // no scope values for this page: hide, keep polling
+ return { param: def.scope.param, values: vals };
+ }
+ return {};
+ }
+
+ function enc(v) { return encodeURIComponent(String(v)); }
+
+ function pollUrl(def, scope) {
+ const params = [];
+ if (def.params) for (const k of Object.keys(def.params)) params.push(enc(k) + "=" + enc(def.params[k]));
+ if (scope && scope.param) params.push(enc(scope.param) + "=" + scope.values.map(enc).join(","));
+ return params.length ? def.src + (def.src.indexOf("?") >= 0 ? "&" : "?") + params.join("&") : def.src;
+ }
+
+ function interval(def) { return Math.max(def.refreshMs || DEFAULT_REFRESH_MS, MIN_REFRESH_MS); }
+
+ function scheduleNext(st, ms) {
+ if (st.timer) clearTimeout(st.timer);
+ st.timer = setTimeout(() => poll(st), Math.max(ms, MIN_REFRESH_MS));
+ }
+
+ function backoffAndRetry(st) {
+ st.backoffMs = st.backoffMs ? Math.min(st.backoffMs * 2, MAX_BACKOFF_MS) : interval(st.def);
+ scheduleNext(st, st.backoffMs);
+ }
+
+ function poll(st) {
+ if (mounted[st.def.id] !== st) return;
+ const def = st.def;
+ const scope = resolveScope(def);
+ if (scope === null) {
+ if (st.el) st.el.hidden = true;
+ scheduleNext(st, interval(def));
+ return;
+ }
+ fetch(pollUrl(def, scope), { cache: "no-store", credentials: "same-origin" }).then(res => {
+ if (res.status === 401 || res.status === 403) {
+ logError("E-JS-65", "badge '%s' fetch failed: '%s'", def.id, res.status);
+ removeBadge(st);
+ return undefined;
+ }
+ return res.json().then(body => {
+ if (mounted[def.id] !== st) return;
+ if (!body || typeof body.total !== "number") {
+ logError("E-JS-66", "badge '%s' response has no numeric 'total'", def.id);
+ markStale(st);
+ backoffAndRetry(st);
+ return;
+ }
+ st.backoffMs = 0;
+ render(st, body);
+ detectDrain(st, body);
+ scheduleNext(st, interval(def));
+ });
+ }).catch(err => {
+ if (mounted[def.id] !== st) return;
+ logError("E-JS-65", "badge '%s' fetch failed: '%s'", def.id, err && err.message);
+ markStale(st);
+ backoffAndRetry(st);
+ });
+ }
+
+ // ---- rendering ----
+
+ function findHost(def) {
+ const doc = window.document;
+ if (def.placement === "toolbar" && def.table) {
+ const card = doc.getElementById(def.table);
+ return card ? (card.querySelector(".jc-ribbon") || card) : null;
+ }
+ let host = doc.querySelector(".jc-header-badges");
+ if (!host) {
+ const parent = doc.querySelector(".jc-header-actions") || doc.querySelector(".jc-header");
+ if (parent) {
+ host = doc.createElement("div");
+ host.className = "jc-header-badges";
+ parent.appendChild(host);
+ }
+ }
+ return host;
+ }
+
+ function ensureEl(st) {
+ if (st.el) return st.el;
+ const def = st.def;
+ const doc = window.document;
+ const el = doc.createElement("button");
+ el.type = "button";
+ el.className = "jc-count-badge";
+ el.setAttribute("role", "status");
+ el.setAttribute("aria-live", "polite");
+ el.setAttribute("data-juneau-badge-tone", TONE_TOKEN[def.tone] || "warning");
+ el.setAttribute("data-juneau-badge-id", def.id);
+ el.hidden = true;
+ el.addEventListener("click", () => onClick(st));
+ el.addEventListener("mouseenter", () => openTooltip(st));
+ el.addEventListener("focus", () => openTooltip(st));
+ el.addEventListener("mouseleave", () => closeTooltip(st));
+ el.addEventListener("blur", () => closeTooltip(st));
+ const host = findHost(def);
+ if (host) host.appendChild(el);
+ st.el = el;
+ return el;
+ }
+
+ function prefersReducedMotion() {
+ return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
+ }
+
+ function render(st, body) {
+ const el = ensureEl(st);
+ const label = st.def.label || {};
+ const prev = st.lastTotal;
+ st.lastTotal = body.total;
+ st.lastBody = body;
+ el.classList.remove("jc-count-badge--stale");
+ el.removeAttribute("aria-label");
+ if (body.total === 0) {
+ el.hidden = true;
+ return;
+ }
+ el.hidden = false;
+ el.setAttribute("data-jc-total", String(body.total));
+ const tmpl = body.total === 1 ? (label.one || "{total} change pending") : (label.other || "{total} changes pending");
+ let text = tmpl.replace(/\{total\}/g, body.total);
+ if (body.mine > 0) text += (label.mine || " ({mine} yours)").replace(/\{mine\}/g, body.mine);
+ el.textContent = text;
+ if (prev != null && body.total > prev) {
+ el.classList.remove("jc-count-badge--pulse");
+ if (!prefersReducedMotion()) el.classList.add("jc-count-badge--pulse");
+ }
+ }
+
+ function markStale(st) {
+ const el = ensureEl(st);
+ el.hidden = false;
+ el.classList.add("jc-count-badge--stale");
+ el.textContent = "!";
+ el.setAttribute("aria-label", "Could not refresh " + st.def.id + " (retrying)");
+ }
+
+ function removeBadge(st) {
+ if (st.timer) clearTimeout(st.timer);
+ closeTooltip(st);
+ if (st.el && st.el.parentNode) st.el.parentNode.removeChild(st.el);
+ if (mounted[st.def.id] === st) delete mounted[st.def.id];
+ }
+
+ // ---- drain detection: an item that left the response means linked cards are stale ----
+
+ function reloadCard(cardId) {
+ const jc = window.JuneauConsole;
+ try {
+ if (jc && typeof jc.refreshCard === "function") {
+ const p = jc.refreshCard(cardId);
+ if (p && typeof p.catch === "function") p.catch(() => {});
+ return;
+ }
+ } catch (e) {
+ // card not mounted on this page: nothing to refresh
+ return;
+ }
+ const card = window.JuneauViews && window.JuneauViews.card;
+ if (card && typeof card.reload === "function") card.reload(cardId);
+ }
+
+ function detectDrain(st, body) {
+ const ids = (body.items || []).map(it => it.id);
+ if (st.seenIds && st.def.refreshes && st.seenIds.some(id => ids.indexOf(id) < 0))
+ for (const cardId of st.def.refreshes) reloadCard(cardId);
+ st.seenIds = ids;
+ }
+
+ // ---- tooltip popover ----
+
+ function openTooltip(st) {
+ const body = st.lastBody;
+ if (!body || !body.items || !body.items.length || !st.el || st.el.hidden) return;
+ closeTooltip(st);
+ const doc = window.document;
+ const def = st.def;
+ const max = def.tooltipMax != null ? Math.max(0, Math.min(50, def.tooltipMax)) : DEFAULT_TOOLTIP_MAX;
+ if (max === 0) return;
+ const pop = doc.createElement("div");
+ pop.className = "jc-count-badge-popover";
+ pop.id = "jc-count-badge-pop-" + def.id;
+ pop.setAttribute("role", "tooltip");
+ const shown = body.items.slice(0, max);
+ for (const it of shown) {
+ const row = doc.createElement(it.href ? "a" : "div");
+ row.className = "jc-count-badge-popover-item" + (it.mine ? " jc-count-badge-popover-item--mine" : "");
+ if (it.href) row.setAttribute("href", it.href);
+ row.textContent = it.label || it.id;
+ pop.appendChild(row);
+ }
+ if (body.items.length > shown.length) {
+ const more = doc.createElement("div");
+ more.className = "jc-count-badge-popover-more";
+ more.textContent = "and " + (body.items.length - shown.length) + " more";
+ pop.appendChild(more);
+ }
+ doc.body.appendChild(pop);
+ st.el.setAttribute("aria-describedby", pop.id);
+ st.popover = pop;
+ }
+
+ function closeTooltip(st) {
+ if (st.popover && st.popover.parentNode) st.popover.parentNode.removeChild(st.popover);
+ st.popover = null;
+ if (st.el) st.el.removeAttribute("aria-describedby");
+ }
+
+ window.document.addEventListener("keydown", ev => {
+ if (ev.key !== "Escape") return;
+ for (const id of Object.keys(mounted)) closeTooltip(mounted[id]);
+ });
+
+ // ---- click navigation ----
+
+ function onClick(st) {
+ const def = st.def;
+ if (!def.href) return;
+ const scope = resolveScope(def);
+ let dest = def.href;
+ const urlState = window.JuneauViews && window.JuneauViews.urlState;
+ if (def.stateFilter && scope && scope.values && scope.values.length && urlState && typeof urlState.encode === "function") {
+ const expr = scope.values.length === 1
+ ? "$eq(" + scope.values[0] + ")"
+ : "$in(" + scope.values.join(",") + ")";
+ const state = urlState.encode({ filters: [{ column: def.stateFilter.column || def.stateFilter, expr: expr }] });
+ dest = def.href + (def.href.indexOf("?") >= 0 ? "&" : "?") + "state=" + enc(state);
+ }
+ window.location.assign(dest);
+ }
+
+ // ---- auto-mount from the console contract ----
+
+ /** Mounts `header.badges` of the contract the console shell has mounted, if any. */
+ NS.mountFromContract = function () {
+ const jc = window.JuneauConsole;
+ const contract = jc && typeof jc.contract === "function" ? jc.contract() : null;
+ const defs = contract && contract.header && contract.header.badges;
+ if (defs && defs.length) NS.mount(defs);
+ };
+
+ NS.mountFromContract();
+})();
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-console.js b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-console.js
index 223ac80036..b4b08c5008 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-console.js
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-console.js
@@ -79,6 +79,42 @@
return msg.replace(/%s/g, () => String(args[i++]));
}
+ // Card visibility: the page's top-level `facts` tested by the card's `visibleWhen` rule (one rule object, or a
+ // list that must all match). Presentation only. This is a deliberate small twin of JuneauViews.rules.test,
+ // because the shell must decide before any card handler (and possibly before the views toolkit) has loaded;
+ // both are held to one shared corpus. A field absent from the facts fails closed, except for `absent`.
+ const FACT_MISSING = {};
+ function factAt(facts, path) {
+ let cur = facts;
+ for (const part of String(path).split(".")) {
+ if (cur === null || typeof cur !== "object" || !(part in cur)) return FACT_MISSING;
+ cur = cur[part];
+ }
+ return cur;
+ }
+ function blankFact(v) { return v === null || v === undefined || String(v).trim() === ""; }
+ function factRuleMatches(rule, facts) {
+ const actual = factAt(facts, rule && rule.field);
+ const missing = actual === FACT_MISSING;
+ switch (rule && rule.op) {
+ case "eq": return !missing && actual === rule.value;
+ case "ne": return !missing && actual !== rule.value;
+ case "present": return !missing && !blankFact(actual);
+ case "absent": return missing || blankFact(actual);
+ case "in": return !missing && Array.isArray(rule.value) && rule.value.includes(actual);
+ case "contains": return !missing && Array.isArray(actual) && actual.includes(rule.value);
+ default:
+ console.error("[juneau-console] " + fmt("visibleWhen rule on field '%s' has unknown op '%s'", rule && rule.field, rule && rule.op));
+ return false;
+ }
+ }
+ function cardVisible(card, facts) {
+ const w = card.visibleWhen;
+ if (w === undefined || w === null) return true;
+ const rules = Array.isArray(w) ? w : [w];
+ return rules.every(r => factRuleMatches(r, facts));
+ }
+
// Every loud failure: one banner listing all failures, console.error, and a throw for fatal codes.
function fail(doc, msgKey, ...args) {
const msg = fmt(MSG[msgKey], ...args);
@@ -582,7 +618,8 @@
const opts = Object.assign({ credentials: "same-origin" }, init);
const method = (opts.method || "GET").toUpperCase();
opts.method = method;
- opts.headers = Object.assign({}, init && init.headers, csrfHeaders(doc, method));
+ // Ask for JSON explicitly: a content-negotiating endpoint answers the browser's default */* with HTML.
+ opts.headers = Object.assign({ Accept: "application/json" }, init && init.headers, csrfHeaders(doc, method));
opts.signal = controller.signal;
return fetch(url, opts).then(function (r) {
if (state.inflight === controller) state.inflight = null;
@@ -737,7 +774,9 @@
if (!t) throw new Error("no <template data-card=\"" + id + "\">");
return clone(doc, t);
};
+ const facts = contract.facts && typeof contract.facts === "object" ? contract.facts : {};
for (const card of contract.cards || []) {
+ if (!cardVisible(card, facts)) continue; // hidden by its visibleWhen rule: never mounted, never fetched
frozenCards.set(card.id, Object.freeze(Object.assign({}, card)));
const bare = card.type === "html" && card.bare === true;
let host;
@@ -746,6 +785,10 @@
} else {
host = el(doc, "div", "jc-card");
host.id = card.id;
+ // Adopter classes follow the built-in one; only well-formed tokens are applied.
+ if (typeof card.class === "string")
+ for (const t of card.class.split(/\s+/))
+ if (/^[A-Za-z_-][A-Za-z0-9_-]*$/.test(t)) host.classList.add(t);
if (card.title) {
const t = el(doc, "h2", "jc-card-title");
t.textContent = card.title;
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-page.schema.json b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-page.schema.json
index 3983358c72..383e264d97 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-page.schema.json
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-page.schema.json
@@ -14,7 +14,8 @@
"navLayout": { "enum": ["horizontal", "vertical"], "default": "horizontal" },
"nav": { "type": "array", "items": { "$ref": "#/$defs/navNode" } },
"activeNav": { "type": "array", "items": { "$ref": "#/$defs/id" } },
- "cards": { "type": "array", "items": { "$ref": "#/$defs/card" } }
+ "cards": { "type": "array", "items": { "$ref": "#/$defs/card" } },
+ "facts": { "type": "object", "description": "Page-level facts (for example the viewer's roles) that visibleWhen rules test; presentation only, never an authorization gate." }
},
"$defs": {
"id": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" },
@@ -76,7 +77,8 @@
"slots": {
"allOf": [ { "$ref": "#/$defs/slots" } ],
"propertyNames": { "enum": ["brand", "actions", "replace", "banner"] }
- }
+ },
+ "badges": { "type": "array", "items": { "$ref": "#/$defs/badge" } }
}
},
"footer": {
@@ -116,6 +118,7 @@
"src": { "$ref": "#/$defs/url" },
"title": { "type": "string" },
"template": { "type": "string", "description": "id of a <template data-card> element" },
+ "class": { "type": "string", "pattern": "^[A-Za-z_-][A-Za-z0-9_-]*( [A-Za-z_-][A-Za-z0-9_-]*)*$", "description": "space-separated CSS classes stamped on the card wrapper element" },
"visibleWhen": { "$ref": "#/$defs/visibleWhen" }
},
"oneOf": [
@@ -129,7 +132,7 @@
"htmlCard": {
"properties": {
"type": { "const": "html" },
- "id": true, "src": true, "title": true, "template": true,
+ "id": true, "src": true, "title": true, "template": true, "class": true,
"bare": { "type": "boolean", "default": false }
},
"additionalProperties": false,
@@ -138,13 +141,10 @@
"datatablesCard": {
"properties": {
"type": { "const": "datatables" },
- "id": true, "title": true, "visibleWhen": true,
+ "id": true, "title": true, "visibleWhen": true, "class": true,
"src": { "$ref": "#/$defs/url" },
"page": { "type": "object" },
- "table": { "anyOf": [
- { "$ref": "#/$defs/tableCatalog" },
- { "type": "object", "required": ["view"], "properties": { "view": { "type": "object" } } }
- ] }
+ "table": { "$ref": "#/$defs/tableCatalog" }
},
"additionalProperties": false,
"oneOf": [
@@ -226,13 +226,81 @@
"rowAction": { "type": "object", "required": ["id", "endpoint"], "description": "RowAction wire shape; later work adds tone/confirmLabel/confirmRenderer/visibleWhen." },
"enabledRule": { "type": "object", "required": ["field", "op"], "properties": { "field": { "type": "string" }, "op": { "type": "string" }, "value": true, "reason": { "type": "string" } } },
"rowDetail": { "type": "object", "required": ["endpoint"], "properties": { "contractVersion": false } },
- "visibleWhen": { "description": "Declarative per-card visibility rule; shape defined with the visibility work." },
- "urlState": { "description": "URL-state options; shape defined with the URL-state work." },
+ "visibleRule": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["field", "op"],
+ "properties": {
+ "field": { "type": "string", "minLength": 1, "description": "Dotted path into the page facts, e.g. viewer.roles." },
+ "op": { "enum": ["eq", "ne", "present", "absent", "in", "contains"] },
+ "value": true
+ }
+ },
+ "badge": {
+ "description": "A count badge fed by a same-origin JSON endpoint returning {total, mine?, items?[]}. Presentation only; the endpoint must authorize the caller.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "src"],
+ "properties": {
+ "id": { "$ref": "#/$defs/id" },
+ "src": { "$ref": "#/$defs/url" },
+ "params": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
+ "refreshMs": { "type": "integer", "default": 60000 },
+ "scope": { "type": "object", "additionalProperties": false, "required": ["param"],
+ "properties": { "param": { "type": "string" }, "by": { "enum": ["nav", "view"], "default": "nav" },
+ "values": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } } } },
+ "label": { "type": "object", "additionalProperties": false,
+ "properties": { "one": { "type": "string" }, "other": { "type": "string" }, "mine": { "type": "string" } } },
+ "tooltipMax": { "type": "integer", "default": 10 },
+ "href": { "$ref": "#/$defs/url" },
+ "stateFilter": { "type": "object", "additionalProperties": false, "required": ["column"], "properties": { "column": { "type": "string" } } },
+ "refreshes": { "type": "array", "items": { "$ref": "#/$defs/id" } },
+ "placement": { "enum": ["header", "toolbar"], "default": "header" },
+ "table": { "$ref": "#/$defs/id" },
+ "tone": { "enum": ["info", "warning", "danger"], "default": "warning" },
+ "visibleWhen": { "$ref": "#/$defs/visibleWhen" }
+ }
+ },
+ "visibleWhen": {
+ "description": "Declarative visibility rule, or a list of rules that must all match; tested against the page's top-level facts. Presentation only.",
+ "oneOf": [
+ { "$ref": "#/$defs/visibleRule" },
+ { "type": "array", "items": { "$ref": "#/$defs/visibleRule" } }
+ ]
+ },
+ "urlState": {
+ "description": "Registers the built-in ?state= directives for this table. Each key present enables that directive.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "window": {
+ "description": "window(start=<iso>;end=<iso>): a time window sent as two request params.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "params": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": { "start": { "type": "string", "minLength": 1 }, "end": { "type": "string", "minLength": 1 } }
+ }
+ }
+ },
+ "ids": {
+ "description": "ids(a,b,c): a de-duplicated id list sent as one comma-joined request param.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "param": { "type": "string", "minLength": 1 },
+ "max": { "type": "integer" }
+ }
+ }
+ }
+ },
"consoleOutputCard": {
"description": "Console-output bridge card; the shell calls JuneauViews.consoleOutput.mount(body, output). Ranges are checked by ConsoleOutputDef.",
"properties": {
"type": { "const": "console-output" },
- "id": true, "title": true,
+ "id": true, "title": true, "class": true,
"output": {
"type": "object",
"properties": {
@@ -259,7 +327,7 @@
"description": "Run-view bridge card; the shell calls JuneauViews.runView.mount(body, runView). Ranges are checked by RunViewDef. eventsUrl is required unless poll is false (anyOf, because the subset validator has no if/then).",
"properties": {
"type": { "const": "run-view" },
- "id": true, "title": true,
+ "id": true, "title": true, "class": true,
"runView": {
"type": "object",
"properties": {
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Serving_Test.java
index c7e3966922..3b344eaf58 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Serving_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Serving_Test.java
@@ -59,6 +59,17 @@ class ConsoleChromeMixin_Serving_Test extends TestBase {
assertEquals(PageContractSchema.get().schemaJson(), body);
}
+ @ParameterizedTest
+ @ValueSource(strings = {"/juneau-console/juneau-badges.js", "/juneau-badges.js"})
+ void a04_badgesJs(String path) throws Exception {
+ var body = C.get(path).run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("javascript")
+ .assertHeader("Cache-Control").isContains("max-age")
+ .getContent().asString();
+ assertTrue(body.contains("window.JuneauConsoleBadges"), "badges body");
+ }
+
@Test void a03_constants() {
assertEquals("/juneau-console/juneau-console.js", ConsoleChromeMixin.CONSOLE_JS_PATH);
assertEquals("/juneau-console/juneau-page.schema.json", ConsoleChromeMixin.SCHEMA_PATH);
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java
index c562ece902..0028cadb96 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java
@@ -39,7 +39,6 @@ import org.junit.jupiter.params.provider.*;
* <pre>
* console-ui → rest-server
* console-ui-freemarker → console-ui + view-freemarker (NOT -datatables)
- * console-ui-freemarker-datatables → console-ui-freemarker + datatables (the ONLY module allowed to touch both)
* datatables → rest-server + bean-html5 (NOT console-ui — stays generic)
* view-freemarker → rest-server + freemarker (NOT -datatables — untouched)
* </pre>
@@ -77,12 +76,6 @@ class ModuleGraph_Test extends TestBase {
var pom = pomOf("juneau-rest-server-datatables");
assertFalse(hasDependency(pom, "juneau-rest-server-console-ui"));
assertFalse(hasDependency(pom, "juneau-rest-server-console-ui-freemarker"));
- assertFalse(hasDependency(pom, "juneau-rest-server-console-ui-freemarker-datatables"));
- }
-
- @Test void a05_consoleUiFreemarkerDatatables_dependsOnBothHalves() throws IOException {
- var pom = pomOf("juneau-rest-server-console-ui-freemarker-datatables");
- assertContainsAll(pom, "<artifactId>juneau-rest-server-console-ui-freemarker</artifactId>", "<artifactId>juneau-rest-server-datatables</artifactId>");
}
@Test void a06_consoleUi_dependsOnlyOnRestServer() throws IOException {
@@ -109,6 +102,12 @@ class ModuleGraph_Test extends TestBase {
assertFalse(restPom.contains("<module>juneau-rest-server-theme-packs</module>"));
}
+ /** The console-ui-freemarker-datatables module (the old <@datatable> macro) was deleted; datatables cards go through the card registry. */
+ @Test void a14_restParentPom_listsNoConsoleUiFreemarkerDatatablesModule() throws IOException {
+ var restPom = Files.readString(new File(new File(System.getProperty("user.dir")).getParentFile(), "pom.xml").toPath());
+ assertFalse(restPom.contains("<module>juneau-rest-server-console-ui-freemarker-datatables</module>"));
+ }
+
/**
* {@code PageContractAssert} (C1-D3) ships at main scope so adopters can use it under any test framework;
* it must not pull in JUnit or opentest4j.
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/PageContractSchema_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/PageContractSchema_Test.java
index 9f01d97efa..c7c4755feb 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/PageContractSchema_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/PageContractSchema_Test.java
@@ -43,7 +43,7 @@ class PageContractSchema_Test extends TestBase {
@ValueSource(strings = {"valid-example.json", "valid-minimal.json", "valid-deep.json", "valid-console-output.json",
"valid-run-view.json", "valid-run-view-nopoll.json",
"valid-datatables-table.json", "valid-datatables-table-full.json", "valid-datatables-rows.json",
- "valid-datatables-src.json", "valid-datatables-slotmeta.json"})
+ "valid-datatables-src.json", "valid-badge.json"})
void a01_validCorpusPasses(String name) throws Exception {
var errors = PageContractSchema.get().validate(fixture(name));
assertTrue(errors.isEmpty(), () -> name + ": " + errors);
@@ -52,6 +52,8 @@ class PageContractSchema_Test extends TestBase {
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
"invalid-version.json|$.contractVersion: must equal 1",
+ "invalid-badge-unknown-key.json|bogus: not allowed",
+ "invalid-badge-refreshms.json|refreshMs",
"invalid-legacy-version.json|missing required 'contractVersion'",
"invalid-legacy-version.json|$.version: not allowed",
"invalid-both-versions.json|$.version: not allowed",
@@ -80,6 +82,7 @@ class PageContractSchema_Test extends TestBase {
"invalid-r3.json|failed at 'b'",
"invalid-datatables-both.json|oneOf",
"invalid-datatables-rows-and-dataurl.json|oneOf",
+ "invalid-datatables-slotmeta.json|oneOf",
"invalid-tablecolumn-no-key.json|oneOf",
"invalid-tablecolumn-extra.json|oneOf",
"invalid-selection-norowid.json|oneOf",
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-refreshms.json b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-refreshms.json
new file mode 100644
index 0000000000..22ef8bd874
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-refreshms.json
@@ -0,0 +1 @@
+{"contractVersion":"1","title":"T","nav":[],"activeNav":[],"cards":[],"header":{"badges":[{"id":"pending","src":"/x","refreshMs":"fast"}]}}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-unknown-key.json b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-unknown-key.json
new file mode 100644
index 0000000000..9f8c62c944
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-unknown-key.json
@@ -0,0 +1 @@
+{"contractVersion":"1","title":"T","nav":[],"activeNav":[],"cards":[],"header":{"badges":[{"id":"pending","src":"/x","bogus":true}]}}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/valid-datatables-slotmeta.json b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-datatables-slotmeta.json
similarity index 100%
rename from juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/valid-datatables-slotmeta.json
rename to juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-datatables-slotmeta.json
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/valid-badge.json b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/valid-badge.json
new file mode 100644
index 0000000000..c9a9abe97f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/valid-badge.json
@@ -0,0 +1 @@
+{"contractVersion":"1","title":"T","nav":[],"activeNav":[],"cards":[],"header":{"badges":[{"id":"pending","src":"/x","refreshMs":60000,"tone":"danger","visibleWhen":[{"field":"viewer.roles","op":"contains","value":"oncall"}]}]}}
diff --git a/juneau-rest/juneau-rest-server-datatables/src/test/java/org/apache/juneau/rest/server/datatables/DataTablesTable_HtmlRenderHonoring_Test.java b/juneau-rest/juneau-rest-server-datatables/src/test/java/org/apache/juneau/rest/server/datatables/DataTablesTable_HtmlRenderHonoring_Test.java
index 6d69121176..0e201d9ec3 100644
--- a/juneau-rest/juneau-rest-server-datatables/src/test/java/org/apache/juneau/rest/server/datatables/DataTablesTable_HtmlRenderHonoring_Test.java
+++ b/juneau-rest/juneau-rest-server-datatables/src/test/java/org/apache/juneau/rest/server/datatables/DataTablesTable_HtmlRenderHonoring_Test.java
@@ -35,7 +35,6 @@ import org.junit.jupiter.api.*;
* <p>
* Per the plan's Phase-6 test-fixture-discipline note (S4), this local {@link LocalFixtureRender} is deliberately
* NOT {@code TagHtmlRender}/{@code console-ui} — this module gains no test-scope dependency on {@code console-ui}.
- * The real {@code .tag}-markup-in-a-real-datatable proof is Phase 7's job, via the actual {@code <@datatable>} macro.
*/
class DataTablesTable_HtmlRenderHonoring_Test extends TestBase {
diff --git a/juneau-rest/juneau-rest-server-views/pom.xml b/juneau-rest/juneau-rest-server-views/pom.xml
index 83f34e3bcf..b65609cfc6 100644
--- a/juneau-rest/juneau-rest-server-views/pom.xml
+++ b/juneau-rest/juneau-rest-server-views/pom.xml
@@ -35,6 +35,11 @@
</properties>
<dependencies>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-run-report</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-server-widgets</artifactId>
@@ -185,6 +190,9 @@
<juneau.jsTests>true</juneau.jsTests>
<juneau.jsTests.dir>${js.tests.dir}</juneau.jsTests.dir>
<juneau.jsTests.harness>${project.basedir}/src/test/js/row-actions.cjs</juneau.jsTests.harness>
+ <!-- Later *_BrowserTest classes resolve their own prober from this directory by name, so each
+ needs neither its own system property nor its own pom edit. -->
+ <juneau.jsTests.harnessDir>${project.basedir}/src/test/js</juneau.jsTests.harnessDir>
<juneau.jsTests.node>${juneau.jsTests.node}</juneau.jsTests.node>
<juneau.jsTests.browsers>${juneau.jsTests.browsers}</juneau.jsTests.browsers>
</systemPropertyVariables>
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BadgeDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BadgeDef.java
new file mode 100644
index 0000000000..228ff58b9c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BadgeDef.java
@@ -0,0 +1,354 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+/**
+ * A count badge in the page contract, fed by a JSON endpoint and polled by {@code juneau-badges.js}.
+ *
+ * <p>
+ * The endpoint returns {@code {total, mine?, items?[]}}. The badge is presentation only: the endpoint must
+ * still authorize the caller, and a page should not declare a badge for users who cannot read it.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jk>var</jk> <jv>badge</jv> = BadgeDef
+ * .<jsm>create</jsm>(<js>"pending"</js>)
+ * .src(<js>"/rest/changes/pending"</js>)
+ * .scope(<js>"beanType"</js>, BadgeDef.ScopeBy.<jsf>NAV</jsf>, Map.<jsm>of</jsm>(<js>"suspensions"</js>, List.<jsm>of</jsm>(<js>"Suspension"</js>)))
+ * .href(<js>"/ui/changes"</js>)
+ * .stateFilter(<js>"beanType"</js>)
+ * .refreshes(<js>"rules-table"</js>)
+ * .refreshMs(60_000);
+ * <jv>badge</jv>.validate(); <jc>// throws IllegalArgumentException with an E-6x message</jc>
+ * </p>
+ *
+ * @since 10.0.0
+ */
+public final class BadgeDef {
+
+ /** The minimum poll interval, in milliseconds (schema §4.8 {@code refreshMs.minimum}). */
+ public static final long MIN_REFRESH_MS = 5000L;
+
+ /** Default poll interval, in milliseconds, applied by {@code juneau-badges.js} when {@link #refreshMs} is unset. */
+ public static final long DEFAULT_REFRESH_MS = 60_000L;
+
+ /** Default tooltip item cap, applied by {@code juneau-badges.js} when {@link #tooltipMax} is unset. */
+ public static final int DEFAULT_TOOLTIP_MAX = 10;
+
+ /** {@code scope.by} wire tokens (schema §4.8). */
+ public enum ScopeBy {
+ /** Keys on the leaf of the page's {@code activeNav}. */
+ NAV("nav"),
+ /** Keys on the active tab of the card named by {@link BadgeDef#table}. */
+ VIEW("view");
+
+ private final String wire;
+ ScopeBy(String wire) { this.wire = wire; }
+ /** @return The lowercase wire token (e.g. <c>"nav"</c>). */
+ public String wire() { return wire; }
+ }
+
+ /** {@code placement} wire tokens (schema §4.8). */
+ public enum Placement {
+ /** Renders into {@code .jc-header-badges} at the end of the header actions area. */
+ HEADER("header"),
+ /** Renders at the right end of {@link BadgeDef#table}'s ribbon. */
+ TOOLBAR("toolbar");
+
+ private final String wire;
+ Placement(String wire) { this.wire = wire; }
+ /** @return The lowercase wire token (e.g. <c>"header"</c>). */
+ public String wire() { return wire; }
+ }
+
+ /** {@code tone} wire tokens (schema §4.8). */
+ public enum Tone {
+ /** Informational tone. */
+ INFO("info"),
+ /** Warning tone (the default). */
+ WARNING("warning"),
+ /** Danger tone. */
+ DANGER("danger");
+
+ private final String wire;
+ Tone(String wire) { this.wire = wire; }
+ /** @return The lowercase wire token (e.g. <c>"warning"</c>). */
+ public String wire() { return wire; }
+ }
+
+ private String id;
+ private String src;
+ private Map<String,Object> params;
+ private Long refreshMs;
+ private String scopeParam;
+ private ScopeBy scopeBy;
+ private Map<String,List<String>> scopeValues;
+ private Map<String,String> label;
+ private Integer tooltipMax;
+ private String href;
+ private String stateFilterColumn;
+ private List<String> refreshes;
+ private Placement placement;
+ private String table;
+ private Tone tone;
+ private List<VisibilityRule> visibleWhen;
+
+ private BadgeDef() {}
+
+ /**
+ * Creates a badge with the given id.
+ *
+ * @param id This badge's own id, unique across every Java- and FTL-declared badge on the page (C3 E-B15).
+ * Must not be <jk>null</jk> or blank.
+ * @return A new {@link BadgeDef}.
+ * @throws IllegalArgumentException If {@code id} is <jk>null</jk> or blank.
+ */
+ public static BadgeDef create(String id) {
+ if (id == null || id.isBlank())
+ throw iaex("BadgeDef id must not be null or blank.");
+ var b = new BadgeDef();
+ b.id = id;
+ return b;
+ }
+
+ /**
+ * Sets the same-origin endpoint this badge polls.
+ *
+ * @param value The endpoint path. Checked for presence, not shape, by {@link #validate()} (E-61).
+ * @return This object.
+ */
+ public BadgeDef src(String value) {
+ src = value;
+ return this;
+ }
+
+ /**
+ * Sets extra request parameters merged into every poll (ahead of the scope params).
+ *
+ * @param value The parameters.
+ * @return This object.
+ */
+ public BadgeDef params(Map<String,Object> value) {
+ params = value;
+ return this;
+ }
+
+ /**
+ * Sets the poll interval.
+ *
+ * @param intervalMs The desired interval, in milliseconds. Checked against {@link #MIN_REFRESH_MS} by
+ * {@link #validate()} (E-62) — unlike {@link RegionDef#refreshMs(long)}, this is rejected, not clamped.
+ * @return This object.
+ */
+ public BadgeDef refreshMs(long intervalMs) {
+ refreshMs = intervalMs;
+ return this;
+ }
+
+ /**
+ * Sets this badge's scope (§4.3): the request param to key the poll on, how to resolve its value, and
+ * (optionally) the authoritative value map.
+ *
+ * @param param The query parameter name sent with the poll. Must not be <jk>null</jk> or blank.
+ * @param by {@link ScopeBy#NAV} or {@link ScopeBy#VIEW}.
+ * @param values The authoritative value map, keyed by nav-leaf or view-tab id, or <jk>null</jk> for an
+ * unscoped badge that still carries a {@code by}.
+ * @return This object.
+ */
+ public BadgeDef scope(String param, ScopeBy by, Map<String,List<String>> values) {
+ scopeParam = param;
+ scopeBy = by;
+ scopeValues = values;
+ return this;
+ }
+
+ /**
+ * Sets this badge's label templates (§4.4).
+ *
+ * @param value A map with any of the keys {@code one}, {@code other}, {@code mine}, each a {@code {total}}/
+ * {@code {mine}} template. Unset keys fall back to the stock IRS-equivalent defaults in {@code juneau-badges.js}.
+ * @return This object.
+ */
+ public BadgeDef label(Map<String,String> value) {
+ label = value;
+ return this;
+ }
+
+ /**
+ * Sets the tooltip popover's item cap (§4.5).
+ *
+ * @param value The cap, 0-50. Not range-checked here; schema `$defs/badge.tooltipMax` enforces it for
+ * FTL-declared badges, and an out-of-range Java value is simply clamped by {@code juneau-badges.js} at
+ * render time.
+ * @return This object.
+ */
+ public BadgeDef tooltipMax(int value) {
+ tooltipMax = value;
+ return this;
+ }
+
+ /**
+ * Sets the click-target URL (§4.6).
+ *
+ * @param value The destination. A same-origin path is strongly recommended; the C1 URL safety check applies
+ * client-side regardless (an unsafe {@code href} is dropped with E-JS-9).
+ * @return This object.
+ */
+ public BadgeDef href(String value) {
+ href = value;
+ return this;
+ }
+
+ /**
+ * Sets the column {@code href}'s destination page filters on, via {@code JuneauViews.urlState.encode(...)}
+ * (§4.6). Requires a non-empty {@link #scope} to have any effect.
+ *
+ * @param column The destination table's filter column name.
+ * @return This object.
+ */
+ public BadgeDef stateFilter(String column) {
+ stateFilterColumn = column;
+ return this;
+ }
+
+ /**
+ * Sets the card ids to {@code JuneauViews.tables.reload(el, {resetPaging: false})} when a previously-seen
+ * item id drains from the badge's response (§4.1 step 6).
+ *
+ * @param value The card ids, in reload order.
+ * @return This object.
+ */
+ public BadgeDef refreshes(String...value) {
+ refreshes = l(value);
+ return this;
+ }
+
+ /**
+ * Sets where this badge renders (§4.7).
+ *
+ * @param value {@link Placement#HEADER} (default) or {@link Placement#TOOLBAR}.
+ * @return This object.
+ */
+ public BadgeDef placement(Placement value) {
+ placement = value;
+ return this;
+ }
+
+ /**
+ * Sets the card id this badge is anchored to. Required when {@link #placement} is {@link Placement#TOOLBAR}
+ * (E-66) and when {@link #scope}'s {@code by} is {@link ScopeBy#VIEW} (R-7, uncoded).
+ *
+ * @param value The card id.
+ * @return This object.
+ */
+ public BadgeDef table(String value) {
+ table = value;
+ return this;
+ }
+
+ /**
+ * Sets this badge's tone (§4.11: Juneau's own tokens, not IRS's gold styling).
+ *
+ * @param value {@link Tone#INFO}, {@link Tone#WARNING} (default) or {@link Tone#DANGER}.
+ * @return This object.
+ */
+ public BadgeDef tone(Tone value) {
+ tone = value;
+ return this;
+ }
+
+ /**
+ * Sets the dynamic-visibility rules gating this badge (§8.2); all must match (AND).
+ *
+ * @param value The rules, or none for an always-visible badge.
+ * @return This object.
+ */
+ public BadgeDef visibleWhen(VisibilityRule...value) {
+ visibleWhen = l(value);
+ return this;
+ }
+
+ /**
+ * Validates this definition.
+ *
+ * <p>
+ * Enforces E-61 (no {@link #src}), E-62 ({@link #refreshMs} below {@link #MIN_REFRESH_MS}), E-66
+ * ({@link Placement#TOOLBAR} without {@link #table}), and R-7 (uncoded: {@link ScopeBy#VIEW} without
+ * {@link #table}). R-6's "table/refreshes name a real card id" and R-8's "scope.values key is a real nav
+ * node id" need the rest of the page and run in {@code PageCapture} at {@code </...@console>} instead (§4.10).
+ *
+ * @throws IllegalArgumentException If this definition is not well-formed.
+ */
+ public void validate() {
+ if (src == null || src.isBlank())
+ throw iaex("<@badge id='%s'> requires src=.", id);
+ if (refreshMs != null && refreshMs < MIN_REFRESH_MS)
+ throw iaex("'%s' refreshMs='%s' is below the minimum '%s'.", id, refreshMs, MIN_REFRESH_MS);
+ if (placement == Placement.TOOLBAR && (table == null || table.isBlank()))
+ throw iaex("<@badge id='%s'> placement='toolbar' requires table=.", id);
+ if (scopeBy == ScopeBy.VIEW && (table == null || table.isBlank()))
+ throw iaex("BadgeDef '%s' scope.by='view' requires table=.", id);
+ }
+
+ /**
+ * Serializes this definition to the compact JSON the {@code header.badges[]} contract carries (§4.8), hand-built
+ * (rather than reflected) so the key set and order match the schema exactly.
+ *
+ * @return An ordered {@link Map} ready for {@code Json.of(...)}.
+ */
+ public Map<String,Object> toContractMap() {
+ var m = new LinkedHashMap<String,Object>();
+ m.put("id", id);
+ if (src != null)
+ m.put("src", src);
+ if (params != null && !params.isEmpty())
+ m.put("params", params);
+ if (refreshMs != null)
+ m.put("refreshMs", refreshMs);
+ if (scopeParam != null) {
+ var scope = new LinkedHashMap<String,Object>();
+ scope.put("param", scopeParam);
+ scope.put("by", (scopeBy == null ? ScopeBy.NAV : scopeBy).wire());
+ if (scopeValues != null && !scopeValues.isEmpty())
+ scope.put("values", scopeValues);
+ m.put("scope", scope);
+ }
+ if (label != null && !label.isEmpty())
+ m.put("label", label);
+ if (tooltipMax != null)
+ m.put("tooltipMax", tooltipMax);
+ if (href != null)
+ m.put("href", href);
+ if (stateFilterColumn != null)
+ m.put("stateFilter", Map.of("column", stateFilterColumn));
+ if (refreshes != null && !refreshes.isEmpty())
+ m.put("refreshes", refreshes);
+ if (placement != null)
+ m.put("placement", placement.wire());
+ if (table != null)
+ m.put("table", table);
+ if (tone != null)
+ m.put("tone", tone.wire());
+ if (visibleWhen != null && !visibleWhen.isEmpty())
+ m.put("visibleWhen", VisibilityRule.toMaps(visibleWhen));
+ return m;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ConsoleOutputChecks.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ConsoleOutputChecks.java
index 4ebc63ad72..52dbd53858 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ConsoleOutputChecks.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ConsoleOutputChecks.java
@@ -18,6 +18,8 @@ package org.apache.juneau.rest.server.views;
import java.util.regex.*;
+import org.apache.juneau.rest.server.runreport.*;
+
/**
* Shared grammars for console-output data: colours, line hrefs, image sources, icon names, tokens.
*
@@ -29,7 +31,6 @@ final class ConsoleOutputChecks {
static final Pattern HEX_COLOR = Pattern.compile("^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$");
static final Pattern RGB_COLOR = Pattern.compile("^rgb\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*\\)$");
- static final Pattern FRAGMENT_HREF = Pattern.compile("^#[A-Za-z0-9._:~-]{0,128}$");
static final Pattern ICON_NAME = Pattern.compile("^[a-z][A-Za-z0-9.-]{0,63}$");
static final Pattern TOKEN = Pattern.compile("^[A-Za-z0-9._~-]{1,128}$");
static final Pattern LOG_ID = Pattern.compile("^[A-Za-z0-9_-]{1,128}$");
@@ -58,12 +59,7 @@ final class ConsoleOutputChecks {
* @return <jk>true</jk> if the href is safe.
*/
static boolean isSafeLineHref(String s) {
- if (s == null)
- return false;
- var t = stripTabCrLf(s);
- if (t.startsWith("#"))
- return FRAGMENT_HREF.matcher(t).matches();
- return isSafePath(t);
+ return RunViewChecks.isSafeLineHref(s);
}
static boolean isSafeLineImageSrc(String s) {
@@ -91,9 +87,7 @@ final class ConsoleOutputChecks {
/** Truncates a value for an error message: 64 chars plus an ellipsis. */
static String clip(String s) {
- if (s == null)
- return "null";
- return s.length() > 64 ? s.substring(0, 64) + "…" : s;
+ return RunViewChecks.clip(s);
}
private static String stripTabCrLf(String s) {
@@ -101,6 +95,6 @@ final class ConsoleOutputChecks {
}
private static boolean isSafePath(String t) {
- return RegionDef.isSafeDetailEndpoint(t.replace('\\', '/'));
+ return RunViewChecks.isSameOriginPath(t.replace('\\', '/'));
}
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FileRunViewSource.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FileRunViewSource.java
index f76d0efb6d..785b58fae6 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FileRunViewSource.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FileRunViewSource.java
@@ -29,6 +29,7 @@ import java.util.regex.*;
import org.apache.juneau.marshall.json.JsonSerializer;
import org.apache.juneau.marshall.marshaller.Json;
+import org.apache.juneau.rest.server.runreport.*;
/**
* A {@link RunViewSource} over one growing JSON Lines file, one event per line, paged through a
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RegionDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RegionDef.java
index 2fe3f3c476..b53717fe53 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RegionDef.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RegionDef.java
@@ -21,6 +21,7 @@ import static org.apache.juneau.commons.utils.Shorts.*;
import java.util.*;
import org.apache.juneau.commons.http.*;
+import org.apache.juneau.rest.server.runreport.RunViewChecks;
/**
* A page "region": a named container whose contents a client-side populator fills in, chosen by name via
@@ -306,6 +307,23 @@ public class RegionDef {
return this;
}
+ /**
+ * The rules that must all match for the whole region to render. <jk>null</jk> or empty means always visible.
+ * See {@link RowAction#visibleWhen} for the shared semantics and evaluation-map convention.
+ */
+ public List<VisibilityRule> visibleWhen;
+
+ /**
+ * Hides this region unless every rule matches.
+ *
+ * @param rules The rules; all must match. Replaces any rules set by an earlier call.
+ * @return This object.
+ */
+ public RegionDef visibleWhen(VisibilityRule...rules) {
+ visibleWhen = l(rules);
+ return this;
+ }
+
/**
* Sets the field catalog.
*
@@ -393,21 +411,7 @@ public class RegionDef {
* @return <jk>true</jk> if the string is a same-origin path template.
*/
public static boolean isSafeDetailEndpoint(String endpoint) {
- if (endpoint == null || endpoint.isBlank())
- return false;
- if (endpoint.contains("://"))
- return false;
- if (endpoint.startsWith("//"))
- return false;
- var colon = endpoint.indexOf(':');
- var slash = endpoint.indexOf('/');
- if (colon >= 0 && (slash < 0 || colon < slash))
- return false;
- for (var seg : endpoint.split("/", -1)) {
- if (eq(seg, ".."))
- return false;
- }
- return true;
+ return RunViewChecks.isSameOriginPath(endpoint);
}
private void validateDataUrlAndParams() {
@@ -503,6 +507,8 @@ public class RegionDef {
if (fields != null && !fields.isEmpty()) {
m.put("fields", fields.stream().map(Field::toContractMap).toList());
}
+ if (visibleWhen != null && !visibleWhen.isEmpty())
+ m.put("visibleWhen", VisibilityRule.toMaps(visibleWhen));
return m;
}
@@ -636,6 +642,23 @@ public class RegionDef {
return this;
}
+ /**
+ * The rules that must all match for this one detail field to render. <jk>null</jk> or empty means always
+ * visible. See {@link RowAction#visibleWhen} for the shared semantics.
+ */
+ public List<VisibilityRule> visibleWhen;
+
+ /**
+ * Hides this field unless every rule matches.
+ *
+ * @param rules The rules; all must match. Replaces any rules set by an earlier call.
+ * @return This object.
+ */
+ public Field visibleWhen(VisibilityRule...rules) {
+ visibleWhen = l(rules);
+ return this;
+ }
+
/**
* Fail-closed validation of this one entry: a non-blank {@link #data}.
*
@@ -668,6 +691,8 @@ public class RegionDef {
m.put("span", "full");
if (actions != null && !actions.isEmpty())
m.put("actions", actions);
+ if (visibleWhen != null && !visibleWhen.isEmpty())
+ m.put("visibleWhen", VisibilityRule.toMaps(visibleWhen));
return m;
}
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
index 7941bc383c..aecd624ccf 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
@@ -59,6 +59,14 @@ import org.apache.juneau.rest.server.widgets.Op;
* <td>What the runtime does with a successful result (see {@link OnSuccess}).</td></tr>
* <tr><td>{@code enabledWhen}</td><td>{@code [{field,op,value?,reason}, ...]}</td>
* <td>Row-state disable-with-reason rules (see {@link #enabledWhen(String,Op,Object,String)}).</td></tr>
+ * <tr><td>{@code visibleWhen}</td><td>{@code [{field,op,value?}, ...]}</td>
+ * <td>Row-state visibility rules (see {@link #visibleWhen(VisibilityRule...)}).</td></tr>
+ * <tr><td>{@code tone}</td><td>{@code default}|{@code danger}</td>
+ * <td>Styling of the confirmation (see {@link Tone}); {@code danger} focuses Cancel first.</td></tr>
+ * <tr><td>{@code confirmLabel}</td><td>string</td>
+ * <td>Text of the confirm button; defaults to the action's {@code label}.</td></tr>
+ * <tr><td>{@code confirmRenderer}</td><td>string</td>
+ * <td>Name of a confirm renderer registered with the client runtime; absent means the built-in modal.</td></tr>
* </table>
*
* <p>
@@ -89,13 +97,14 @@ import org.apache.juneau.rest.server.widgets.Op;
*
* <h5 class='section'>Example:</h5>
* <p class='bjava'>
- * RowAction <jv>ack</jv> = RowAction.<jsm>create</jsm>(<js>"ack"</js>)
- * .label(<js>"Acknowledge"</js>)
- * .endpoint(<js>"servlet:/incidents/{id}/ack"</js>)
- * .method(RowAction.Method.<jsf>POST</jsf>)
- * .confirm(<js>"Acknowledge this incident?"</js>)
- * .present(RowAction.Present.<jsf>DIALOG</jsf>)
- * .onSuccess(RowAction.OnSuccess.<jsf>MERGE_ROW</jsf>);
+ * RowAction <jv>delete</jv> = RowAction.<jsm>create</jsm>(<js>"delete"</js>)
+ * .label(<js>"Delete"</js>)
+ * .endpoint(<js>"servlet:/incidents/{id}"</js>)
+ * .method(RowAction.Method.<jsf>DELETE</jsf>)
+ * .confirm(<js>"Delete incident {id}?"</js>)
+ * .confirmLabel(<js>"Delete forever"</js>)
+ * .tone(RowAction.Tone.<jsf>DANGER</jsf>)
+ * .visibleWhen(VisibilityRule.<jsm>when</jsm>(<js>"locked"</js>).ne(<jk>true</jk>));
* </p>
*
* <h5 class='section'>See Also:</h5>
@@ -105,7 +114,7 @@ import org.apache.juneau.rest.server.widgets.Op;
*
* @since 10.0.0
*/
-@BeanType(properties="id,label,icon,endpoint,method,confirm,confirmTitle,bulkMode,form,present,onSuccess,enabledWhen")
+@BeanType(properties="id,label,icon,endpoint,method,confirm,confirmTitle,bulkMode,form,present,onSuccess,enabledWhen,visibleWhen,tone,confirmLabel,confirmRenderer")
@SuppressWarnings({
"java:S1845" // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
})
@@ -144,6 +153,37 @@ public class RowAction {
}
}
+ /**
+ * The visual tone of a row action's confirmation.
+ *
+ * <p>
+ * Each constant carries the lowercase wire token emitted for the {@code tone} field. {@link #DANGER} styles the
+ * confirmation as destructive and focuses its Cancel button first.
+ */
+ public enum Tone {
+
+ /** The ordinary confirmation styling. */
+ DEFAULT("default"),
+
+ /** A destructive confirmation: danger styling, Cancel focused first. */
+ DANGER("danger");
+
+ private final String wire;
+
+ Tone(String wire) {
+ this.wire = wire;
+ }
+
+ /**
+ * Returns the lowercase wire token for this tone.
+ *
+ * @return The wire token (e.g. {@code "danger"}).
+ */
+ public String wire() {
+ return wire;
+ }
+ }
+
/**
* How a row action's form/confirmation is presented.
*
@@ -288,6 +328,28 @@ public class RowAction {
*/
public List<RowActionEnabledRule> enabledWhen;
+ /**
+ * The rules that must all match for this action to render at all. <jk>null</jk> or empty means always visible.
+ *
+ * <p>
+ * Unlike {@link #enabledWhen}, which disables but never hides, a row whose data does not satisfy every rule
+ * here has this action hidden entirely (re-evaluated on every draw). Prefer {@code enabledWhen}, which tells
+ * the user why an action is unavailable, and hide only when the action is irrelevant to the row. Still
+ * presentation only — the endpoint must authorize independently. The evaluation map is the row, plus the
+ * page's {@code facts} under a {@code facts.} prefix, so a rule can test either {@code "status"} or
+ * {@code "facts.viewer.roles"}.
+ */
+ public List<VisibilityRule> visibleWhen;
+
+ /** Optional confirmation tone wire token (see {@link Tone#wire()}). */
+ public String tone;
+
+ /** Optional text of the confirm button; <jk>null</jk> means the action's {@link #label}. */
+ public String confirmLabel;
+
+ /** Optional name of a confirm renderer registered with the client runtime; <jk>null</jk> means the built-in modal. */
+ public String confirmRenderer;
+
/**
* Starts a new {@link RowAction} with the specified stable action id.
*
@@ -532,4 +594,52 @@ public class RowAction {
enabledWhen.add(rule);
return this;
}
+
+ /**
+ * Hides this action, per row, unless every rule matches (see {@link #visibleWhen} for the semantics).
+ *
+ * @param rules The rules; all must match. Replaces any rules set by an earlier call.
+ * @return This object.
+ */
+ public RowAction visibleWhen(VisibilityRule...rules) {
+ visibleWhen = l(rules);
+ return this;
+ }
+
+ /**
+ * Sets the confirmation tone.
+ *
+ * @param value The tone. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public RowAction tone(Tone value) {
+ tone = value.wire();
+ return this;
+ }
+
+ /**
+ * Sets the text of the confirm button.
+ *
+ * @param value The label. Can be <jk>null</jk> to fall back to {@link #label}.
+ * @return This object.
+ */
+ public RowAction confirmLabel(String value) {
+ confirmLabel = value;
+ return this;
+ }
+
+ /**
+ * Sets the name of the confirm renderer to use for this action's confirmation.
+ *
+ * <p>
+ * The name must be registered on the client with {@code JuneauViews.rowActions.registerConfirmRenderer}; an
+ * unregistered name refuses the confirmation.
+ *
+ * @param value The renderer name. Can be <jk>null</jk> for the built-in modal.
+ * @return This object.
+ */
+ public RowAction confirmRenderer(String value) {
+ confirmRenderer = value;
+ return this;
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowActionEnabledRule.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowActionEnabledRule.java
index 1bd34ab74d..5c11f6a3d6 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowActionEnabledRule.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowActionEnabledRule.java
@@ -81,6 +81,8 @@ public class RowActionEnabledRule {
public static RowActionEnabledRule of(String field, Op op, Object value, String reason) {
if (field == null || field.isBlank())
throw iaex("RowActionEnabledRule field must not be null or blank.");
+ if (op != null && op.isCollectionOp())
+ throw iaex("RowActionEnabledRule op '%s' is a visibility-rule operator; use eq or ne.", op.wire());
if (op == null || ! op.requiresValue())
throw iaex("RowActionEnabledRule op '%s' does not take a value; use the (field, op, reason) form.",
op == null ? "null" : op.wire());
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewDef.java
index 0c51526189..cac29a20f2 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewDef.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewDef.java
@@ -20,6 +20,7 @@ import static org.apache.juneau.commons.utils.Shorts.*;
import static org.apache.juneau.rest.server.views.ConsoleOutputChecks.clip;
import java.util.*;
+import org.apache.juneau.rest.server.runreport.*;
/**
* The author-facing declaration of a run-view region, compiled to a {@link RegionDef}.
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewEndpoints.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewEndpoints.java
index a818aab792..588541c7ff 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewEndpoints.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewEndpoints.java
@@ -24,6 +24,7 @@ import org.apache.juneau.http.response.Gone;
import org.apache.juneau.http.response.NotFound;
import org.apache.juneau.marshall.json.*;
import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.runreport.*;
/**
* Static helpers that implement the run-view events endpoint.
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewLog.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewLog.java
index 7e4551a52a..60755cce22 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewLog.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewLog.java
@@ -21,6 +21,7 @@ import static org.apache.juneau.commons.utils.Shorts.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.*;
+import org.apache.juneau.rest.server.runreport.*;
/**
* A thread-safe, in-memory {@link RunViewSource} that a producer appends to directly.
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewPage.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewPage.java
index 3c3af2a5a5..77e6a39e17 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewPage.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewPage.java
@@ -21,6 +21,7 @@ import static org.apache.juneau.commons.utils.Shorts.*;
import java.util.*;
import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.rest.server.runreport.*;
/**
* One page of a run-view event stream: the envelope the events endpoint returns and the client polls.
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
index 7ba2f03006..8189ca8b19 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
@@ -350,26 +350,9 @@ public class ViewsMixin {
* catalogs emit this on the lifted {@code view} object.
*
* <p>
- * Templates should normally <b>omit</b> {@code view.contractVersion}: when a pre-built envelope leaves it out, the
- * framework injects this value, so a contract bump never touches adopter templates. A template that pins a
- * different value logs a {@code WARNING} naming the template, card and both versions, and fails the render in dev
- * mode and tests (and is flagged statically by {@code ConsoleTemplateValidator}'s {@code view-contract-version}
- * rule). Pin a version only to deliberately test the mismatch path.
- *
- * <h5 class='section'>Example:</h5>
- * <p class='bcode'>
- * <@card type="datatables" id="orders">
- * {
- * layout: 'wide',
- * view: {
- * <jc>// No contractVersion here: the runtime's "{@value #CONTRACT_VERSION}" is injected.</jc>
- * id: 'orders',
- * dataUrl: '/orders/data',
- * columns: [ { data: 'name', title: 'Name' } ]
- * }
- * }
- * </@card>
- * </p>
+ * Templates never author {@code view.contractVersion}: the framework stamps this value when it lifts the catalog,
+ * so a contract bump never touches adopter templates. A pre-built envelope body is rejected at render time, and
+ * {@code ConsoleTemplateValidator}'s {@code view-contract-version} rule flags a literal pin statically.
*/
public static final String CONTRACT_VERSION = "5";
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/VisibilityRule.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/VisibilityRule.java
new file mode 100644
index 0000000000..f7f3d201b6
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/VisibilityRule.java
@@ -0,0 +1,207 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+import org.apache.juneau.rest.server.widgets.Op;
+
+/**
+ * A presentation-only visibility rule: the element is shown only when every rule in its list matches.
+ *
+ * <p>
+ * Rules hide UI; they never authorize. The endpoint behind a hidden action must still check the caller.
+ * Use {@code visible(false)} instead when the element must not reach the client at all.
+ *
+ * <p>
+ * The evaluation map depends on the element's level. A row action, detail field or region sees the row plus the
+ * page's {@code facts} under a {@code facts.} prefix; a card, badge or ribbon item sees the page's top-level
+ * {@code facts} alone. A field absent from the map fails closed for every operator except {@code absent}.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * RowAction.<jsm>create</jsm>(<js>"escalate"</js>)
+ * .visibleWhen(
+ * VisibilityRule.<jsm>when</jsm>(<js>"status"</js>).in(<js>"open"</js>, <js>"acked"</js>),
+ * VisibilityRule.<jsm>when</jsm>(<js>"facts.viewer.roles"</js>).contains(<js>"oncall"</js>));
+ * </p>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="field,op,value")
+public final class VisibilityRule {
+
+ private static final Object MISSING = new Object();
+ private static final Set<String> KNOWN_OPS = Set.of("eq", "ne", "present", "absent", "in", "contains");
+
+ /** The field this rule tests, a dotted path into the evaluation map (e.g. {@code "facts.viewer.roles"}). */
+ public String field;
+
+ /** The wire token of the comparison operator (see {@link Op#wire()}). */
+ public String op;
+
+ /** The comparison value for {@code eq}/{@code ne}/{@code in}/{@code contains}; omitted for {@code present}/{@code absent}. */
+ public Object value;
+
+ private VisibilityRule() {}
+
+ /**
+ * Starts a rule for the given field.
+ *
+ * @param field The dotted-path field to test. Must not be <jk>null</jk> or blank.
+ * @return A {@link Builder} to pick the operator.
+ * @throws IllegalArgumentException If {@code field} is <jk>null</jk> or blank.
+ */
+ public static Builder when(String field) {
+ if (field == null || field.isBlank())
+ throw iaex("VisibilityRule field must not be null or blank.");
+ return new Builder(field);
+ }
+
+ /**
+ * Evaluates a list of rules against an evaluation map; every rule must match (AND).
+ *
+ * <p>
+ * A <jk>null</jk> or empty list always matches. A field absent from {@code map} never satisfies
+ * {@code eq}/{@code ne}/{@code in}/{@code contains}/{@code present}; only {@code absent} treats a missing field
+ * as a match. Mirrors the JS {@code JuneauViews.rules.test} evaluator exactly.
+ *
+ * @param rules The rules to test, or <jk>null</jk>.
+ * @param map The evaluation map.
+ * @return <jk>true</jk> when every rule matches.
+ * @throws IllegalArgumentException If a rule's {@code op} is not one of the known tokens.
+ */
+ public static boolean test(List<VisibilityRule> rules, Map<String,Object> map) {
+ if (rules == null || rules.isEmpty())
+ return true;
+ for (var r : rules)
+ if (! r.matches(map))
+ return false;
+ return true;
+ }
+
+ /**
+ * Serializes this rule to its wire shape.
+ *
+ * @return An ordered {@link Map} of {@code field}, {@code op} and (when set) {@code value}.
+ */
+ public Map<String,Object> toMap() {
+ var m = new LinkedHashMap<String,Object>();
+ m.put("field", field);
+ m.put("op", op);
+ if (value != null)
+ m.put("value", value);
+ return m;
+ }
+
+ static List<Map<String,Object>> toMaps(List<VisibilityRule> rules) {
+ return rules.stream().map(VisibilityRule::toMap).toList();
+ }
+
+ private boolean matches(Map<String,Object> map) {
+ if (! KNOWN_OPS.contains(op))
+ throw iaex("VisibilityRule op '%s' is not one of '%s'.", op, String.join(", ", new TreeSet<>(KNOWN_OPS)));
+ var actual = resolve(map, field);
+ var missing = actual == MISSING;
+ return switch (op) {
+ case "eq" -> ! missing && Objects.equals(actual, value);
+ case "ne" -> ! missing && ! Objects.equals(actual, value);
+ case "present" -> ! missing && actual != null && ! (actual instanceof String s && s.isBlank());
+ case "absent" -> missing || actual == null || (actual instanceof String s && s.isBlank());
+ case "in" -> ! missing && value instanceof Collection<?> c && c.contains(actual);
+ default -> ! missing && actual instanceof Collection<?> c && c.contains(value);
+ };
+ }
+
+ private static Object resolve(Map<?,?> map, String dottedPath) {
+ Object cur = map;
+ for (var part : dottedPath.split("\\."))
+ if (cur instanceof Map<?,?> m && m.containsKey(part))
+ cur = m.get(part);
+ else
+ return MISSING;
+ return cur;
+ }
+
+ /** Fluent operator picker returned by {@link VisibilityRule#when(String)}. */
+ public static final class Builder {
+ private final String field;
+
+ private Builder(String field) {
+ this.field = field;
+ }
+
+ /**
+ * Matches when the field equals the value.
+ *
+ * @param value The comparison value. Must not be <jk>null</jk>.
+ * @return A new {@link VisibilityRule}.
+ */
+ public VisibilityRule eq(Object value) { return of(Op.EQ, value); }
+
+ /**
+ * Matches when the field is present and does not equal the value.
+ *
+ * @param value The comparison value. Must not be <jk>null</jk>.
+ * @return A new {@link VisibilityRule}.
+ */
+ public VisibilityRule ne(Object value) { return of(Op.NE, value); }
+
+ /**
+ * Matches when the field is non-null and non-blank.
+ *
+ * @return A new {@link VisibilityRule}.
+ */
+ public VisibilityRule present() { return of(Op.PRESENT, null); }
+
+ /**
+ * Matches when the field is missing, null or blank.
+ *
+ * @return A new {@link VisibilityRule}.
+ */
+ public VisibilityRule absent() { return of(Op.ABSENT, null); }
+
+ /**
+ * Matches when the field equals one of the values.
+ *
+ * @param values The candidate values.
+ * @return A new {@link VisibilityRule}.
+ */
+ public VisibilityRule in(Object...values) { return of(Op.IN, List.of(values)); }
+
+ /**
+ * Matches when the field (itself a list) contains the value.
+ *
+ * @param value The value the list must contain. Must not be <jk>null</jk>.
+ * @return A new {@link VisibilityRule}.
+ */
+ public VisibilityRule contains(Object value) { return of(Op.CONTAINS, value); }
+
+ private VisibilityRule of(Op op, Object value) {
+ if (op.requiresValue() && value == null)
+ throw iaex("VisibilityRule op '%s' requires a non-null value.", op.wire());
+ var r = new VisibilityRule();
+ r.field = field;
+ r.op = op.wire();
+ r.value = value;
+ return r;
+ }
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/report/package-info.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/report/package-info.java
deleted file mode 100644
index 7156ca70f3..0000000000
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/report/package-info.java
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * 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.
- */
-
-/**
- * Readers that turn test-report files into run-view events.
- *
- * <p>
- * Each {@link org.apache.juneau.rest.server.views.report.ReportReader} reads one report format (Surefire and JUnit
- * XML, Jest JSON, Playwright JSON) into a {@link org.apache.juneau.rest.server.views.report.ReportResult}, whose
- * {@code toEvents(step)} yields the {@code replace}, {@code test} and {@code note} events a producer appends to a
- * run view when a step ends. A reader never throws for bad input: a missing, oversize, malformed or truncated file
- * yields the tests that could be recovered plus a warning.
- *
- * <p>
- * Use {@link org.apache.juneau.rest.server.views.report.ReportReaders#forKind(java.lang.String)} to pick a reader by
- * its report kind.
- */
-package org.apache.juneau.rest.server.views.report;
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-helpers.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-helpers.js
index afcbc337b2..06bf2a9e69 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-helpers.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-helpers.js
@@ -802,6 +802,8 @@
for (const fieldsEntry of fields) {
const field = fieldsEntry;
+ // visibleWhen: tested against the values map plus the page facts under `facts`; presentation only.
+ if (field.visibleWhen && typeof NS.rules?.testRow === "function" && !NS.rules.testRow(field.visibleWhen, values)) continue;
const item = document.createElement("div");
item.className = "juneau-view-detail-field" + (field.span ? " juneau-view-detail-field-span-" + field.span : "");
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-regions.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-regions.js
index 6b3e5b278e..7035bac40b 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-regions.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-regions.js
@@ -1232,15 +1232,18 @@
+ REGION_CONTRACT_VERSION + "'); it is read as identity-only.");
return empty;
}
+ const declared = {
+ dataUrl: body.dataUrl != null ? String(body.dataUrl) : null,
+ renderer: body.renderer != null ? String(body.renderer) : null,
+ lazy: typeof body.lazy === "boolean" ? body.lazy : defaultLazyFor(type),
+ refreshMs: typeof body.refreshMs === "number" ? clampRefreshMs(body.refreshMs) : null,
+ fields: Array.isArray(body.fields) ? body.fields : null,
+ titleFields: Array.isArray(body.titleFields) ? body.titleFields : null
+ };
+ // Present only when the region declares one, so a region without it keeps its exact prior shape.
+ if (Array.isArray(body.visibleWhen) && body.visibleWhen.length) declared.visibleWhen = body.visibleWhen;
return {
- declared: {
- dataUrl: body.dataUrl != null ? String(body.dataUrl) : null,
- renderer: body.renderer != null ? String(body.renderer) : null,
- lazy: typeof body.lazy === "boolean" ? body.lazy : defaultLazyFor(type),
- refreshMs: typeof body.refreshMs === "number" ? clampRefreshMs(body.refreshMs) : null,
- fields: Array.isArray(body.fields) ? body.fields : null,
- titleFields: Array.isArray(body.titleFields) ? body.titleFields : null
- },
+ declared: declared,
params: (body.params && typeof body.params === "object" && !Array.isArray(body.params)) ? body.params : {}
};
}
@@ -1366,6 +1369,10 @@
liveRegions.push(region);
el.setAttribute(REGION_STATE_ATTR, "idle");
armBarrier();
+ if (hiddenByVisibleWhen(region)) {
+ el.hidden = true;
+ el.setAttribute("data-juneau-region-hidden", "rule");
+ }
if (isRegionHidden(el)) {
region.deferred = true;
return region;
@@ -1375,6 +1382,18 @@
return region;
}
+ /**
+ * Whether the region's {@code visibleWhen} rules say it should not show. The evaluation map is the owning row
+ * (when the region sits in a row-detail panel) plus the page facts under {@code facts}. A hidden region takes the
+ * existing hidden path: it is enrolled but deferred, and never populates. Presentation only.
+ */
+ function hiddenByVisibleWhen(region) {
+ const rules = region.declared.visibleWhen;
+ if (!rules || typeof NS.rules?.testRow !== "function") return false;
+ const row = NS.rowActions?.contextOf?.(region.el)?.row;
+ return !NS.rules.testRow(rules, row);
+ }
+
/**
* The baseline refusal. A blank container with no error is the worst failure this runtime has, and a silently
* signal-less region is a subtler version of it - so every region gets the error state, and the PAGE gets exactly
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
index feb9749de4..0608270451 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
@@ -453,6 +453,24 @@
"class": function () { return "pill-cell juneau-cell-wrap"; }
});
+ // `edit:<actionId>`: the cell shows the value and a pencil button. juneau-views.js owns the behavior: it gates the
+ // pencil on the action's visibleWhen/enabledWhen and, on click, swaps the cell to an input that commits through the
+ // action. meta: `action` (or the `:<actionId>` sugar), `input` (`text` | `number` | `select`), `options` for select.
+ registerRenderer("edit", {
+ display: function (cellData, rowData, meta) {
+ const action = meta?.action ?? meta?.field;
+ const value = cellData == null ? "" : String(cellData);
+ if (!action) return escHtml(value);
+ const input = meta?.input === "number" || meta?.input === "select" ? meta.input : "text";
+ const options = meta?.options == null ? "" : (Array.isArray(meta.options) ? JSON.stringify(meta.options) : String(meta.options));
+ return '<span class="juneau-edit-cell"><span class="juneau-edit-value">' + escHtml(value) + "</span>"
+ + '<button type="button" class="juneau-edit-pencil" aria-label="Edit" data-juneau-edit="' + escAttr(action) + '"'
+ + ' data-juneau-edit-col="' + escAttr(meta?.column ?? "") + '" data-juneau-edit-input="' + input + '"'
+ + (options ? ' data-juneau-edit-options="' + escAttr(options) + '"' : "") + "></button></span>";
+ },
+ "class": function () { return "juneau-cell-wrap"; }
+ });
+
// The fill-sink variant of `pill`, registered straight into the frozen set rather than snapshotted from the
// registry above: a sink pill is unconditionally DISPLAY-ONLY. A fill sink has no rowActions in scope, so it can
// never emit `role="button"`/`tabindex`/`data-juneau-action` even if an author smuggles a `meta.action` past the
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
index 6e4ce93a0d..0ac82d5bb2 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
@@ -19,6 +19,7 @@
* juneau-ribbon.js - ribbon/toolbar runtime for the Apache Juneau rich-view toolkit.
*
* Builds the toolbar from viewDef.ribbon: export (feature-detected copy/csv/print via DataTables Buttons, with
+ * a hard `buttons` entry whose JSZip/pdfMake is missing rendered disabled and logged as E-JS-69, never dropped, and
* excel/pdf lit up only when JSZip/pdfMake are present), refresh, pausePolling, collapseAll,
* dialog (a row-less, ribbon-hosted dialog, opened through juneau-views.js's ribbon-catalog resolver),
* option/optionGroup server-query toggles (with persisted state), and divider.
@@ -50,6 +51,13 @@
const NS = window.JuneauViews = window.JuneauViews || {};
+ /** One-line `%s`-replacer for this file's own error strings (each script keeps its own private copy). */
+ function fmt(msg) {
+ const args = Array.prototype.slice.call(arguments, 1);
+ let i = 0;
+ return String(msg).replace(/%s/g, function () { return i < args.length ? String(args[i++]) : "%s"; });
+ }
+
// ==================================================================================================================
// PURE LOGIC LAYER (no DOM, no jQuery, no DataTables)
// ==================================================================================================================
@@ -263,20 +271,39 @@
}
/**
- * Given an `export` action and detected features, returns the button ids that should actually be offered. With no
- * Buttons extension the result is empty (graceful degrade); optional excel/pdf are included only when their extra
- * dep is present, otherwise omitted.
+ * Button id -> `features` key of the extra dependency it needs, for ids a caller may put in the HARD `buttons` list.
+ * `copy`/`csv`/`print`/`collapse` need nothing beyond the Buttons extension itself, so they have no entry here.
+ */
+ const EXPORT_BUTTON_DEPS = { excel: "jszip", pdf: "pdfmake" };
+
+ /** Message (and E-JS-69 log text) for a hard export button whose extra library is absent. */
+ const EXPORT_MISSING_MSG = "ribbon export '%s' needs '%s'; load it before juneau-views.js or list the button in optional.";
+
+ /** Human-readable library name for a `features` key, for the disabled-button tip and the E-JS-69 log. */
+ const EXPORT_LIB_DISPLAY_NAME = { jszip: "JSZip", pdfmake: "pdfMake" };
+
+ /**
+ * Given an `export` action and detected features, returns `{ok, missing}`. `ok` is the button ids to render and
+ * register with DataTables Buttons. `missing` is `{id, needs}` for every id in the HARD `buttons` list whose
+ * required extra dependency is absent: those are not silently dropped, the caller renders them disabled instead.
+ * An id in the opt-in `optional` list whose dependency is absent is simply omitted (that list exists to opt out of
+ * a hard requirement). With no Buttons extension at all both arrays are empty (graceful degrade).
*/
function resolveExportButtons(action, features) {
- const out = [];
- if (!features?.buttons) return out; // degrade gracefully - no export cluster at all
- (action.buttons || []).forEach(function (b) { out.push(b); });
- (action.optional || []).forEach(function (b) {
+ const ok = [];
+ const missing = [];
+ if (!features?.buttons) return { ok: ok, missing: missing }; // degrade gracefully - no export cluster at all
+ for (const b of (action.buttons || [])) {
+ const needs = EXPORT_BUTTON_DEPS[b];
+ if (needs && !features[needs]) missing.push({ id: b, needs: needs });
+ else ok.push(b);
+ }
+ for (const b of (action.optional || [])) {
// Both branches merely gate on their own extra dep being present - combined rather than duplicated.
- if ((b === "excel" && features.jszip) || (b === "pdf" && features.pdfmake)) out.push(b);
- // else: dep absent - omit/grey (feature-detected)
- });
- return out;
+ if ((b === "excel" && features.jszip) || (b === "pdf" && features.pdfmake)) ok.push(b);
+ // else: dep absent - omitted, not "missing" (opt-in degrade)
+ }
+ return { ok: ok, missing: missing };
}
/** localStorage key for a persisted ribbon toggle (VIEW_META §6.8: juneau.view.<viewId>.ribbon.<optionId>). */
@@ -452,8 +479,19 @@
// few lines and several are pinned verbatim by the wiring canary tests below `functionBody(body, "function
// buildRibbon(")`, so splitting them into further helpers would reduce test/code locality without reducing
// real complexity.
+ /**
+ * Whether a ribbon item is shown: one with no {@code visibleWhen} always is. A rule list is tested against the
+ * page's top-level facts alone (a ribbon item has no row). Presentation only; a page that somehow loaded this
+ * runtime without the rules runtime shows the item rather than hiding it.
+ */
+ function ribbonItemVisible(a) {
+ if (a?.visibleWhen == null) return true;
+ const rules = NS.rules;
+ return typeof rules?.test !== "function" || rules.test(a.visibleWhen, rules.facts());
+ }
+
function buildRibbon(viewDef, ctx) {
- const actions = normalizeRibbon(viewDef.ribbon || []);
+ const actions = normalizeRibbon(viewDef.ribbon || []).filter(ribbonItemVisible);
if (!actions.length) return null;
const $ = window.jQuery;
@@ -487,7 +525,17 @@
return;
}
if (a.type === "export") {
- const ids = resolveExportButtons(a, features);
+ const resolved = resolveExportButtons(a, features);
+ const ids = resolved.ok;
+ const exportGroupId = a.group ?? "__ungrouped";
+ // A button in the HARD `buttons` list whose library is absent renders DISABLED with a tip naming the
+ // library; the first miss on this ctx is logged once as E-JS-69.
+ if (resolved.missing.length && !ctx.__exportLibWarned) {
+ ctx.__exportLibWarned = true;
+ const first = resolved.missing[0];
+ console.error("[juneau-ribbon] E-JS-69: " + fmt(EXPORT_MISSING_MSG, first.id, EXPORT_LIB_DISPLAY_NAME[first.needs] || first.needs));
+ }
+ let registered = false;
if (ids.length && ctx.dataTable && $?.fn?.dataTable?.Buttons) {
try {
// Still registers/feature-gates each button with DataTables Buttons - but renders our own
@@ -497,14 +545,26 @@
buttons: ids,
exportOptions: { columns: ":visible" }
});
- const exportGroupId = a.group ?? "__ungrouped";
- for (const id of ids) {
- place(button(id, resolveButtonIcon(null, id), function () {
- ctx.dataTable.button(id).trigger();
- }, a.appearance), exportGroupId);
- }
+ registered = true;
} catch (e) { /* Buttons present but init failed - degrade silently */ } // NOSONAR javascript:S2486 -- documented best-effort degrade
}
+ // Declared order is kept: a missing-library button sits where it was listed, disabled.
+ const declared = (a.buttons || []).concat(a.optional || []);
+ const rendered = (registered ? ids : []).concat(resolved.missing.map(function (m) { return m.id; }));
+ rendered.sort(function (x, y) { return declared.indexOf(x) - declared.indexOf(y); });
+ for (const id of rendered) {
+ const miss = resolved.missing.find(function (m) { return m.id === id; });
+ if (miss) {
+ const dead = button(id, resolveButtonIcon(null, id), function () { /* disabled: nothing to run */ }, a.appearance);
+ dead.disabled = true;
+ stampChromeTip(dead, fmt(EXPORT_MISSING_MSG, id, EXPORT_LIB_DISPLAY_NAME[miss.needs] || miss.needs));
+ place(dead, exportGroupId);
+ } else {
+ place(button(id, resolveButtonIcon(null, id), function () {
+ ctx.dataTable.button(id).trigger();
+ }, a.appearance), exportGroupId);
+ }
+ }
return;
}
if (a.type === "refresh") {
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-run-view.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-run-view.js
index 81799d85e1..6a5ed1e680 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-run-view.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-run-view.js
@@ -34,6 +34,8 @@
const TEST_STATUSES = ["pass", "fail", "skip", "error"], LEVELS = ["info", "warn", "error"], DONE_STATUSES = ["ok", "fail", "cancelled"];
const KINDS = ["step", "end", "suite", "test", "replace", "note", "done"];
const FRAGMENT_HREF = /^#[A-Za-z0-9._:~-]{0,128}$/;
+ // A re-run of a step is "<id>.2", "<id>.3", ... titled "<title> (attempt 2)", ...; the first attempt keeps the plain id.
+ const ATTEMPT_ID = /^(.+)\.([2-9]|[1-9][0-9]+)$/, ATTEMPT_TITLE = /^(.*) \(attempt ([2-9]|[1-9][0-9]+)\)$/;
// @section:checks
@@ -387,6 +389,38 @@
return out;
}
+ /**
+ * Groups the re-runs of a step. An attempt is a step whose id is "<base>.<n>" (n >= 2) for an existing step "<base>",
+ * or, failing that, whose title is "<title> (attempt <n>)" for an existing step titled "<title>". Returns an array of
+ * {base, latest, earlier} (stream order, the last attempt being the latest) for every step that has at least one
+ * earlier attempt.
+ */
+ function attemptGroups(model) {
+ const byBase = new Map();
+ const byTitle = new Map();
+ for (const st of model.steps)
+ if (!byTitle.has(st.title)) byTitle.set(st.title, st);
+ for (const st of model.steps) {
+ let base = null;
+ const mi = ATTEMPT_ID.exec(st.id);
+ if (mi && model.stepIndex.has(mi[1])) {
+ base = mi[1];
+ } else {
+ const mt = ATTEMPT_TITLE.exec(st.title);
+ const first = mt ? byTitle.get(mt[1]) : null;
+ if (first && first !== st) base = first.id;
+ }
+ if (base !== null) {
+ let g = byBase.get(base);
+ if (!g) { g = [model.stepIndex.get(base)]; byBase.set(base, g); }
+ g.push(st);
+ }
+ }
+ const out = [];
+ byBase.forEach(function (g, base) { out.push({ base: base, latest: g[g.length - 1], earlier: g.slice(0, g.length - 1) }); });
+ return out;
+ }
+
/**
* The derived view of a model: run status and headline, each step's shown state (open steps close to fail/skip once a
* done exists), exact per-framework counts and the failures list (step order, then suite order, then arrival order).
@@ -433,7 +467,8 @@
}
}
const counts = model.fwOrder.filter(function (fw) { return byFw.has(fw); }).map(function (fw) { return byFw.get(fw); });
- return { status: status, headline: headline, glyph: glyph, steps: shown, counts: counts, failures: failures };
+ return { status: status, headline: headline, glyph: glyph, steps: shown, counts: counts, failures: failures,
+ attempts: attemptGroups(model) };
}
// @section:render
@@ -729,6 +764,63 @@
st.dirty = false;
}
+ /**
+ * Nests the earlier attempts of a re-run step under its latest attempt, behind an "N earlier attempts" toggle that is
+ * collapsed by default. A run with no re-run steps is untouched.
+ */
+ function renderAttempts(inst, d) {
+ const latestIds = new Set();
+ inst.attemptOf = new Map();
+ for (const g of d.attempts) {
+ const rec = inst.recs.get(g.latest.id);
+ if (!rec) continue;
+ latestIds.add(g.latest.id);
+ const open = inst.attemptsOpen.has(g.base);
+ if (!rec.attemptsBtn) {
+ const btn = mk("button", "juneau-rv-attempts-toggle");
+ attr(btn, "type", "button");
+ attr(btn, "data-juneau-rv-act", "toggle-attempts");
+ rec.attemptsChev = mk("span", "juneau-rv-attempts-chevron");
+ rec.attemptsLabel = mk("span", "juneau-rv-attempts-label");
+ attr(rec.attemptsChev, "aria-hidden", "true");
+ btn.appendChild(rec.attemptsChev);
+ btn.appendChild(rec.attemptsLabel);
+ rec.attemptsBtn = btn;
+ rec.attemptsEl = mk("ul", "juneau-rv-attempts");
+ rec.li.insertBefore(btn, rec.suitesEl);
+ rec.li.insertBefore(rec.attemptsEl, rec.suitesEl);
+ }
+ const n = g.earlier.length;
+ attr(rec.attemptsBtn, "data-juneau-rv-key", g.base);
+ attr(rec.attemptsBtn, "aria-expanded", open ? "true" : "false");
+ rec.attemptsChev.textContent = open ? "â–¾" : "â–¸";
+ rec.attemptsLabel.textContent = n + (n === 1 ? " earlier attempt" : " earlier attempts");
+ rec.attemptsEl.hidden = !open;
+ g.earlier.forEach(function (st, i) {
+ inst.attemptOf.set(st.id, g.base);
+ const r = inst.recs.get(st.id);
+ if (!r) return;
+ attr(r.li, "data-juneau-rv-attempt", "earlier");
+ if (r.attemptsBtn) {
+ r.attemptsBtn.remove();
+ r.attemptsEl.remove();
+ r.attemptsBtn = null;
+ r.attemptsEl = null;
+ }
+ if (rec.attemptsEl.childNodes[i] !== r.li)
+ rec.attemptsEl.appendChild(r.li);
+ });
+ }
+ inst.recs.forEach(function (rec, id) {
+ if (rec.attemptsBtn && !latestIds.has(id)) {
+ rec.attemptsBtn.remove();
+ rec.attemptsEl.remove();
+ rec.attemptsBtn = null;
+ rec.attemptsEl = null;
+ }
+ });
+ }
+
function failureText(f) {
if (f.test === null)
return f.suite.suite + ": " + f.suite.fail + " failed";
@@ -793,6 +885,7 @@
for (const x of d.steps)
if (x.step.dirty || closing || !inst.recs.has(x.step.id))
renderStep(inst, x.step, x.shown);
+ renderAttempts(inst, d);
if (m.runDirty) {
renderNotes(inst.runNotesEl, m.runNotes);
m.runDirty = false;
@@ -948,6 +1041,8 @@
function revealFailure(inst, info, withTrace) {
const ek = ekeyOf(info.step, info.suite);
+ const base = inst.attemptOf.get(info.step.id);
+ if (base !== undefined) inst.attemptsOpen.add(base);
inst.expanded.set(ek, true);
if (withTrace && info.test) inst.trace.set(ek, info.test);
info.step.dirty = true;
@@ -978,6 +1073,11 @@
return;
}
const key = act.getAttribute("data-juneau-rv-key");
+ if (a === "toggle-attempts") {
+ if (inst.attemptsOpen.has(key)) inst.attemptsOpen.delete(key); else inst.attemptsOpen.add(key);
+ renderNow(inst);
+ return;
+ }
const stepId = a === "toggle-clean" ? key : key.slice(0, key.indexOf("/"));
const st = inst.model.stepIndex.get(stepId);
const ek = a === "toggle-clean" ? key + "/*clean" : key;
@@ -1088,6 +1188,8 @@
inst.recs = new Map();
inst.lastDone = null;
inst.trace = new Map();
+ inst.attemptsOpen = new Set();
+ inst.attemptOf = new Map();
inst.stepsEl.replaceChildren();
inst.runNotesEl.replaceChildren();
hideTip(inst);
@@ -1122,7 +1224,7 @@
const o = checkOptions(el, options);
const inst = {
id: o.id, host: el, compact: o.compact, rawHref: o.rawHref, emit: o.emit, model: newModel(), recs: new Map(),
- expanded: new Map(), trace: new Map(), warned: new Set(), listeners: [], timer: null, renders: 0, destroyed: false,
+ expanded: new Map(), trace: new Map(), attemptsOpen: new Set(), attemptOf: new Map(), warned: new Set(), listeners: [], timer: null, renders: 0, destroyed: false,
lastDone: null, lastStatus: "empty", errored: false, tipFor: null, stopPoll: null, api: null, banner: null, connEl: null
};
const root = mk("section", "juneau-rv" + (o.compact ? " juneau-rv-compact" : ""));
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-urlstate.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-urlstate.js
index 8493658892..5998cf574a 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-urlstate.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-urlstate.js
@@ -19,7 +19,7 @@
* juneau-urlstate.js - the shareable "Copy link" URL-state codec (design section 6.3).
*
* A single, dependency-free codec for the ONE opaque query parameter named `state`, whose value uses the Juneau
- * directives tab(...), filter(...), and sort(...) and NOTHING else. It attaches to window.JuneauViews.urlState
+ * directives tab(...), filter(...), and sort(...) plus any directive a page registers via registerDirective. It attaches to window.JuneauViews.urlState
* (mirroring the other dependency-free pre-views globals: the icon registry, the column-search engine, and the
* page-state store), so it loads as a plain <script> BEFORE juneau-views.js and juneau-config.js, which consume it.
*
@@ -46,6 +46,135 @@
function isBlank(v) { return v == null || String(v) === ''; }
+ // -------------------------------------------------------------------------------------------------------------
+ // Directive registry: tab/filter/sort are built in; a page may add more (the built-ins `window` and `ids` below).
+ // -------------------------------------------------------------------------------------------------------------
+
+ const RESERVED_DIRECTIVES = { tab: true, filter: true, sort: true };
+ const DIRECTIVE_NAME_RE = /^[a-z][a-zA-Z0-9]{0,31}$/;
+ const registry = Object.create(null);
+ const directiveOrder = []; // registration order, so encodeState appends ext directives deterministically
+
+ /**
+ * Registers a `?state=` directive beyond the built-in tab/filter/sort.
+ *
+ * <p>
+ * `codec` is `{ parse(body) -> value, serialize(value) -> body, isEmpty?(value), toParams?(value) -> object,
+ * apply?(value, table) }`. `parse` and `serialize` must be inverses; build bodies with {@link encodeArg} so a value
+ * carrying the grammar's own separators round-trips. A reserved, malformed, or already-registered name logs
+ * E-JS-67 and the directive simply does not exist.
+ *
+ * @example
+ * NS.urlState.registerDirective('view', { parse: String, serialize: String });
+ */
+ function registerDirective(name, codec) {
+ if (RESERVED_DIRECTIVES[name] || ! DIRECTIVE_NAME_RE.test(String(name)) || registry[name]
+ || ! codec || typeof codec.parse !== 'function' || typeof codec.serialize !== 'function') {
+ console.error('[juneau-urlstate] E-JS-67: url-state directive \'' + name + '\' is reserved, malformed, or already registered');
+ return false;
+ }
+ registry[name] = { codec: codec };
+ directiveOrder.push(name);
+ return true;
+ }
+
+ /** Whether a directive is registered under `name` (built-in tab/filter/sort are not registry entries). */
+ function hasDirective(name) { return !! registry[name]; }
+
+ /** The registered codec for `name`, or null. */
+ function directiveCodec(name) { return registry[name] ? registry[name].codec : null; }
+
+ /** The registered directive names, in registration order. */
+ function directiveNames() { return directiveOrder.slice(); }
+
+ /** Percent-encodes the characters the directive grammar is sensitive to, so a codec body always parses. */
+ function encodeArg(s) {
+ return String(s == null ? '' : s).replace(/[;(),=%]/g, function (c) { return '%' + c.charCodeAt(0).toString(16).toUpperCase(); });
+ }
+
+ /** Decodes an {@link encodeArg} value; a malformed %-sequence is kept as-is. */
+ function decodeArg(s) {
+ try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } // NOSONAR javascript:S2486 -- a malformed %-sequence keeps the raw text
+ }
+
+ /** Whether a registered directive's value is empty (so it is omitted from the encoded state). */
+ function extValueEmpty(codec, value) {
+ return codec.isEmpty ? !! codec.isEmpty(value) : (value == null || value === '');
+ }
+
+ /** Built-in `window` directive: body `start=<iso>;end=<iso>`, either half optional; `params` renames the request params. */
+ function windowDirectiveCodec(cfg) {
+ const params = (cfg && cfg.params) || {};
+ const startParam = params.start || 'start';
+ const endParam = params.end || 'end';
+ return {
+ parse: function (body) {
+ const out = {};
+ for (const part of String(body || '').split(';')) {
+ const eq = part.indexOf('=');
+ if (eq <= 0) continue;
+ const k = part.substring(0, eq);
+ if (k === 'start' || k === 'end') out[k] = decodeArg(part.substring(eq + 1));
+ }
+ return out;
+ },
+ serialize: function (v) {
+ const out = [];
+ if (v?.start) out.push('start=' + encodeArg(v.start));
+ if (v?.end) out.push('end=' + encodeArg(v.end));
+ return out.join(';');
+ },
+ isEmpty: function (v) { return ! v || (! v.start && ! v.end); },
+ toParams: function (v) {
+ const out = {};
+ if (v?.start) out[startParam] = v.start;
+ if (v?.end) out[endParam] = v.end;
+ return out;
+ }
+ };
+ }
+
+ /** Built-in `ids` directive: body `a,b,c`; trims, drops blanks, dedupes, and caps at `max` (default 200). */
+ function idsDirectiveCodec(cfg) {
+ const param = cfg?.param || 'ids';
+ const max = cfg?.max || 200;
+ function normalize(raw) {
+ const seen = Object.create(null);
+ const ids = [];
+ for (const part of String(raw || '').split(',')) {
+ const v = decodeArg(part).trim();
+ if (v === '') continue;
+ if (seen[v] || ids.length >= max) continue;
+ seen[v] = true;
+ ids.push(v);
+ }
+ return { ids: ids, truncated: hasDroppedBeyondCap(raw, ids, max) };
+ }
+ return {
+ parse: function (body) { return normalize(body); },
+ serialize: function (v) { return (v?.ids || []).map(encodeArg).join(','); },
+ isEmpty: function (v) { return ! v?.ids || v.ids.length === 0; },
+ toParams: function (v) {
+ const out = {};
+ out[param] = (v?.ids || []).join(',');
+ return out;
+ },
+ statusMessage: function (v) { return v?.truncated ? ('Showing the first ' + max + ' ids') : null; }
+ };
+ }
+
+ /** Whether any distinct non-blank id in `raw` was left out of `kept` purely because of the cap (dupes do not count). */
+ function hasDroppedBeyondCap(raw, kept, max) {
+ if (kept.length < max) return false;
+ const keptSet = Object.create(null);
+ for (const k of kept) keptSet[k] = true;
+ for (const part of String(raw || '').split(',')) {
+ const v = decodeArg(part).trim();
+ if (v !== '' && ! keptSet[v]) return true;
+ }
+ return false;
+ }
+
// -------------------------------------------------------------------------------------------------------------
// Codec: {tab, filters:[{column,expr}], sort:{column,dir}} <-> "tab(x);filter(col=expr);sort(col=dir)"
// -------------------------------------------------------------------------------------------------------------
@@ -72,6 +201,14 @@
const sort = state.sort;
if (sort && ! isBlank(sort.column) && ! isBlank(sort.dir))
parts.push('sort(' + clauseEscape(String(sort.column)) + '=' + sort.dir + ')');
+ const ext = state.ext || {};
+ for (const extName of directiveOrder) {
+ if (! (extName in ext)) continue;
+ const codec = registry[extName].codec;
+ if (extValueEmpty(codec, ext[extName])) continue;
+ // '%' is doubled so decodeState's one whole-string percent-decode leaves the codec's own %XX escapes intact.
+ parts.push(extName + '(' + String(codec.serialize(ext[extName])).replace(/%/g, '%25') + ')');
+ }
return parts.join(';');
}
@@ -203,7 +340,7 @@
* S4: Discarding until the next top-level `;` after a malformed directive.
*/
function decodeState(str) { // NOSONAR javascript:S3776 -- URL state parsing; complexity is inherent
- const state = { tab: null, filters: [], sort: null };
+ const state = { tab: null, filters: [], sort: null, ext: {} };
if (str == null) return state;
let raw = String(str);
if (raw.indexOf('%') >= 0) {
@@ -233,8 +370,15 @@
const sortClauses = parseClauses(b);
if (sortClauses.length > 0 && sortClauses[0].key !== '')
state.sort = { column: sortClauses[0].key, dir: sortClauses[0].value };
+ } else if (registry[n]) {
+ try {
+ state.ext[n] = registry[n].codec.parse(b);
+ } catch (e) {
+ // E-JS-68: the directive is dropped; tab/filter/sort already decoded are kept.
+ console.error('[juneau-urlstate] E-JS-68: url-state directive \'' + n + '\' could not be parsed: ' + (e?.message || e));
+ }
}
- // Any other directive name is deliberately ignored (design section 6.3: tab/filter/sort ONLY).
+ // An unregistered directive name is dropped (design section 6.3, extended: tab/filter/sort plus whatever the page registered).
}
function flushPending() {
@@ -310,9 +454,16 @@
return state;
}
- /** Whether a decoded state carries no tab, no filters, and no sort (i.e. nothing to restore). */
+ /** Whether a decoded state carries no tab, no filters, and no sort (i.e. nothing to restore); registered directives count too. */
function isEmptyState(state) {
- return ! state || (isBlank(state.tab) && (! state.filters || state.filters.length === 0) && ! state.sort);
+ if (! state) return true;
+ if (! isBlank(state.tab) || (state.filters && state.filters.length > 0) || state.sort) return false;
+ const ext = state.ext || {};
+ for (const name of Object.keys(ext)) {
+ const codec = registry[name]?.codec;
+ if (codec && ! extValueEmpty(codec, ext[name])) return false;
+ }
+ return true;
}
// -------------------------------------------------------------------------------------------------------------
@@ -417,6 +568,23 @@
buildShareUrl: buildShareUrl,
copy: copy,
cleanAddressEnabled: cleanAddressEnabled,
- resolveOpenState: resolveOpenState
+ resolveOpenState: resolveOpenState,
+ registerDirective: registerDirective,
+ hasDirective: hasDirective,
+ directiveCodec: directiveCodec,
+ directiveNames: directiveNames,
+ encodeArg: encodeArg,
+ decodeArg: decodeArg,
+ builtins: { window: windowDirectiveCodec, ids: idsDirectiveCodec },
+ // Needs a live table, which this DOM-free module never has: delegates to juneau-views.js at call time, so
+ // either script load order works.
+ setDirective: function (tableEl, name, value) {
+ const run = NS.init?.setUrlStateDirective;
+ if (typeof run !== 'function') {
+ console.error('[juneau-urlstate] E-JS-67: setDirective needs juneau-views.js loaded');
+ return false;
+ }
+ return run(tableEl, name, value);
+ }
};
})();
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
index 86467c9ec0..d87b5594b2 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
@@ -2871,6 +2871,54 @@ input[type="checkbox"].jc-editable-field-input {
box-sizing: border-box;
}
+/*
+ * Inline cell edit (the edit renderer): a value with a pencil that swaps the cell to an input.
+ */
+.juneau-edit-pencil {
+ margin-left: 0.35em;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ vertical-align: middle;
+ width: 1em;
+ height: 1em;
+ line-height: 1;
+ opacity: 0.55;
+}
+
+.juneau-edit-pencil::before {
+ content: "\270E";
+}
+
+.juneau-edit-pencil:hover,
+.juneau-edit-pencil:focus-visible {
+ opacity: 1;
+}
+
+.juneau-edit-pencil[disabled],
+.juneau-edit-pencil[aria-disabled="true"] {
+ cursor: not-allowed;
+ opacity: 0.3;
+}
+
+.juneau-edit-editor {
+ display: inline-flex;
+ flex-direction: column;
+ gap: 0.2em;
+}
+
+.juneau-edit-input {
+ font: inherit;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+
+.juneau-edit-error {
+ font-size: 0.85em;
+ color: var(--jc-chrome-danger, currentColor);
+}
+
.jc-cell-popover {
position: fixed;
z-index: var(--jc-popover-z);
@@ -3440,6 +3488,36 @@ input[type="checkbox"].jc-editable-field-input {
padding: var(--jc-space-1) 0;
}
+.juneau-rv-attempts-toggle {
+ font: inherit;
+ color: var(--jc-tone-info);
+ background: none;
+ border: 0;
+ padding: 0;
+ text-align: left;
+ cursor: pointer;
+}
+
+.juneau-rv-attempts-toggle:hover .juneau-rv-attempts-label {
+ text-decoration: underline;
+}
+
+.juneau-rv-attempts-chevron {
+ display: inline-block;
+ width: 1.2em;
+}
+
+.juneau-rv-attempts {
+ list-style: none;
+ margin: 0;
+ padding: 0 0 0 var(--jc-space-4);
+ opacity: 0.85;
+}
+
+.juneau-rv-attempts[hidden] {
+ display: none;
+}
+
.juneau-rv-step-head {
display: flex;
flex-wrap: wrap;
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
index 8d56bf6cc7..c0bd26d2c5 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
@@ -209,8 +209,6 @@
* Lift a datatables card's author catalog (`card.table`) into the SLOT_META envelope `paintSlotTable` consumes,
* stamping every contract version (slot, view, bulk, detail, quickStats).
*
- * A catalog whose `view` is an object is a pre-built SLOT_META envelope (escape hatch) and passes through unchanged.
- *
* @example
* NS.card.liftCatalog('releases', {dataUrl: '/rest/releases/data',
* columns: [{key: 'version', label: 'Version'}], selection: {rowIdField: 'version'}});
@@ -226,7 +224,6 @@
NS.card.liftCatalog = function liftCatalog(cardId, catalog) {
if (!catalog || typeof catalog !== "object" || Array.isArray(catalog))
throw new Error("datatables card '" + cardId + "': 'table' must be an object");
- if (catalog.view && typeof catalog.view === "object") return catalog;
if (!Array.isArray(catalog.columns) || catalog.columns.length === 0)
throw new Error("datatables card '" + cardId + "': 'columns' must be a non-empty array");
const hasUrl = typeof catalog.dataUrl === "string" && catalog.dataUrl.trim() !== "";
@@ -1100,6 +1097,7 @@
} else {
opts.serverSide = false;
opts.ajax = { url: viewDef.dataUrl, dataSrc: "" };
+ if (deps?.urlStateParams) opts.ajax.data = function () { return deps.urlStateParams(); };
}
// Nested-table parent scoping. `deps.nestedScope`, present ONLY for a nested table, merges exactly ONE extra
@@ -1187,7 +1185,7 @@
// URL-only params (any `param:`-scoped ribbon option; applyNestedScope wraps this to add the
// nested-table parent-scope param) - never a column-scoped one; those go through `data` above.
beforeSend: function (jqXHR, settings) {
- const extra = ribbon ? ribbon.ribbonQueryParams(viewDef, activeState()) : {};
+ const extra = Object.assign({}, ribbon ? ribbon.ribbonQueryParams(viewDef, activeState()) : {}, deps?.urlStateParams ? deps.urlStateParams() : {});
settings.url = appendQueryParams(viewDef.dataUrl, extra);
// Send the CSRF token with the server-mode data request, resolved per request exactly as the
// row-action path does (table attribute first, then the page meta tag); no token, no header.
@@ -3575,6 +3573,77 @@
return null;
}
+ // ==================================================================================================================
+ // VISIBILITY RULES - declarative hide-when-irrelevant, shared by row actions, detail fields, regions, ribbon items
+ // ==================================================================================================================
+ //
+ // A rule is {field, op, value?} with op one of eq/ne/present/absent/in/contains; a list of rules is an AND. This
+ // is deliberately NOT actionRuleMatches: that evaluator keeps its string-coerced eq and its own fail-closed
+ // `absent` for enabledWhen, and changing it would change every existing disabled-with-reason gate. Here `field`
+ // is a dotted path, comparison is strict, and a field absent from the evaluation map fails closed for every op
+ // except `absent`. JuneauViews.rules.test is held to the same corpus as the Java VisibilityRule.test.
+ //
+ // The evaluation map is the row plus the page's facts under `facts` (row actions, detail fields, regions), or the
+ // facts alone (ribbon items). Hiding is presentation only - the endpoint must still authorize.
+
+ const RULE_FIELD_MISSING = {};
+
+ function ruleValueAt(map, dottedPath) {
+ let cur = map;
+ for (const part of String(dottedPath).split(".")) {
+ if (cur === null || typeof cur !== "object" || !(part in cur)) return RULE_FIELD_MISSING;
+ cur = cur[part];
+ }
+ return cur;
+ }
+
+ function isBlankRuleValue(v) {
+ return v === null || v === undefined || String(v).trim() === "";
+ }
+
+ function visibilityRuleMatches(rule, map) {
+ const actual = ruleValueAt(map, rule?.field);
+ const missing = actual === RULE_FIELD_MISSING;
+ switch (rule?.op) {
+ case "eq": return !missing && actual === rule.value;
+ case "ne": return !missing && actual !== rule.value;
+ case "present": return !missing && !isBlankRuleValue(actual);
+ case "absent": return missing || isBlankRuleValue(actual);
+ case "in": return !missing && Array.isArray(rule.value) && rule.value.includes(actual);
+ case "contains": return !missing && Array.isArray(actual) && actual.includes(rule.value);
+ default:
+ // E-JS-73: an unknown op hides the element (fail closed) but never throws, so the rest of the table still renders.
+ console.error("[juneau-views] E-JS-73: visibleWhen rule on field '" + rule?.field + "' has unknown op '" + rule?.op + "'");
+ return false;
+ }
+ }
+
+ /** True when every rule matches `map`; null/empty matches; a single rule object is a list of one. */
+ function evaluateRules(rules, map) {
+ if (rules === null || rules === undefined) return true;
+ const list = Array.isArray(rules) ? rules : [rules];
+ const scope = map && typeof map === "object" ? map : {};
+ return list.every(function (r) { return visibilityRuleMatches(r, scope); });
+ }
+
+ /** The mounted console page's top-level `facts`, or `{}` when there is no console page or it declares none. */
+ function pageFacts() {
+ let contract = null;
+ try { contract = window.JuneauConsole?.contract?.(); } catch (e) { contract = null; }
+ const facts = contract?.facts;
+ return facts && typeof facts === "object" ? facts : {};
+ }
+
+ /** The evaluation map for a row-level element: the row's own fields plus the page facts under `facts`. */
+ function rowRuleScope(rowData) {
+ return Object.assign({}, rowData && typeof rowData === "object" ? rowData : {}, { facts: pageFacts() });
+ }
+
+ /** Whether `action` is shown for the row; an action with no `visibleWhen` always is. */
+ function isRowActionVisible(action, rowData) {
+ return !action?.visibleWhen?.length || evaluateRules(action.visibleWhen, rowRuleScope(rowData));
+ }
+
// ==================================================================================================================
// ROWACTION.ENABLEDWHEN - row-state disable-with-reason gate
// ==================================================================================================================
@@ -3652,9 +3721,36 @@
if (!rowEl || typeof rowEl.querySelectorAll !== "function" || !viewDef?.rowActions?.length) return;
for (const pill of rowEl.querySelectorAll("[data-juneau-pill][data-juneau-action]")) {
const action = findRowActionById(viewDef, pill.dataset.juneauAction);
+ if (action && !isRowActionVisible(action, rowData)) {
+ pill.hidden = true;
+ pill.setAttribute("aria-hidden", "true");
+ continue;
+ }
const failing = firstFailingRowActionRule(action, rowData);
if (failing) disableRowActionPill(pill, failing.reason);
}
+ // The menu trigger has nothing to open when every action is hidden for this row.
+ const anyVisible = viewDef.rowActions.some(function (a) { return isRowActionVisible(a, rowData); });
+ const trigger = rowEl.querySelector(".juneau-view-action-trigger");
+ if (trigger && !anyVisible) trigger.hidden = true;
+ }
+
+ /**
+ * Gates the pencil of every `edit` renderer cell on the row: hidden when the action it commits through is not
+ * visible for this row (`visibleWhen`), disabled with the failing rule's reason when `enabledWhen` fails. An
+ * action the view does not declare hides the pencil and is reported once per table draw.
+ */
+ function applyInlineEditGates(rowEl, rowData, viewDef) {
+ if (!rowEl || typeof rowEl.querySelectorAll !== "function") return;
+ for (const pencil of rowEl.querySelectorAll("[data-juneau-edit]")) {
+ const action = findRowActionById(viewDef, pencil.getAttribute("data-juneau-edit"));
+ if (!action || !isRowActionVisible(action, rowData)) {
+ pencil.hidden = true;
+ continue;
+ }
+ const failing = firstFailingRowActionRule(action, rowData);
+ if (failing) disableRowActionControl(pencil, failing.reason);
+ }
}
/**
@@ -4236,6 +4332,129 @@
});
}
+ // ==================================================================================================================
+ // INLINE CELL EDIT (the `edit` renderer): the cell shows the value and a pencil; the pencil swaps the cell to an
+ // input that commits through the named row action.
+ // ==================================================================================================================
+
+ /** Parses the `options` of a `select` edit cell: an array, or a comma-separated string (render meta values are strings). */
+ function parseEditOptions(raw) {
+ if (Array.isArray(raw)) return raw.map(String);
+ if (typeof raw !== "string" || raw === "") return [];
+ const parsed = parseJsonSafe(raw);
+ if (Array.isArray(parsed)) return parsed.map(String);
+ return raw.split(",").map(function (o) { return o.trim(); }).filter(function (o) { return o !== ""; });
+ }
+
+ function buildEditInput(input, options, current) {
+ let field;
+ if (input === "select" && options.length) {
+ field = document.createElement("select");
+ for (const o of options) {
+ const opt = document.createElement("option");
+ opt.value = o;
+ opt.textContent = o;
+ field.appendChild(opt);
+ }
+ field.value = current;
+ } else {
+ field = document.createElement("input");
+ field.type = input === "number" ? "number" : "text";
+ field.value = current;
+ }
+ field.className = "juneau-edit-input";
+ return field;
+ }
+
+ /** Swaps the pencil's cell into edit mode. A cell already being edited is left alone. */
+ function startInlineEdit(pencil, table, ctx, viewDef) {
+ const cell = pencil.closest ? pencil.closest("td") : null;
+ const tr = cell?.parentNode;
+ if (!cell || !tr || cell._juneauEditing) return;
+ const action = findRowActionById(viewDef, pencil.getAttribute("data-juneau-edit"));
+ const rowData = rowDataForTr(ctx, tr) || {};
+ // Re-checked at activation, not only at draw, so a stale or bypassed gate can never open the editor.
+ if (!action || !isRowActionVisible(action, rowData) || firstFailingRowActionRule(action, rowData)) return;
+ const column = pencil.getAttribute("data-juneau-edit-col");
+ const current = rowData[column] == null ? "" : String(rowData[column]);
+ const field = buildEditInput(pencil.getAttribute("data-juneau-edit-input"),
+ parseEditOptions(pencil.getAttribute("data-juneau-edit-options")), current);
+ field.setAttribute("aria-label", "Edit " + column);
+ const error = document.createElement("span");
+ error.className = "juneau-edit-error";
+ error.setAttribute("role", "alert");
+ error.hidden = true;
+ const editor = document.createElement("span");
+ editor.className = "juneau-edit-editor";
+ editor.appendChild(field);
+ editor.appendChild(error);
+ const saved = Array.from(cell.childNodes);
+ for (const n of saved) cell.removeChild(n);
+ cell.appendChild(editor);
+ const state = { busy: false, failed: false, done: false };
+ cell._juneauEditing = state;
+
+ function close(newValue) {
+ if (state.done) return;
+ state.done = true;
+ delete cell._juneauEditing;
+ if (editor.parentNode === cell) cell.removeChild(editor);
+ for (const n of saved) cell.appendChild(n);
+ if (newValue != null) {
+ const shown = cell.querySelector(".juneau-edit-value");
+ if (shown) shown.textContent = newValue;
+ }
+ if (typeof pencil.focus === "function") pencil.focus();
+ }
+ function commit() {
+ if (state.busy || state.done) return;
+ const value = field.value;
+ if (value === current) { close(null); return; }
+ state.busy = true;
+ state.failed = false;
+ field.disabled = true;
+ error.hidden = true;
+ const fields = {};
+ fields[column] = value;
+ submitRowActionForOutcome(action, table, tr, ctx, { fields: fields }).then(function (o) {
+ state.busy = false;
+ field.disabled = false;
+ if (o.outcome === "success") {
+ applySuccessBehavior(action, table, tr, ctx, o.result);
+ const redrawn = action.onSuccess === "redraw" || (action.onSuccess === "mergeRow" && o.result?.row != null);
+ if (!redrawn && ctx?.dataTable) reloadTableData(ctx.dataTable, true); // keep the current page
+ close(value);
+ return;
+ }
+ state.failed = true;
+ error.textContent = o.message || "The change could not be saved.";
+ error.hidden = false;
+ if (typeof field.focus === "function") field.focus();
+ });
+ }
+ field.addEventListener("keydown", function (e) {
+ if (e.key === "Enter") { e.preventDefault(); commit(); }
+ else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); if (!state.busy) close(null); }
+ });
+ // A failed save stays open until the operator retries (Enter) or cancels (Escape); only an untried edit commits on blur.
+ field.addEventListener("blur", function () { if (!state.failed) commit(); });
+ if (typeof field.focus === "function") field.focus();
+ }
+
+ /** ONE delegated listener per table that opens the editor from an `edit` renderer's pencil. */
+ function initInlineEdit(table, ctx, viewDef) {
+ if (!table || typeof table.addEventListener !== "function" || table._juneauInlineEditBound) return;
+ table._juneauInlineEditBound = true;
+ table.addEventListener("click", function (e) {
+ if (!isOwnTableEvent(table, e)) return; // a nested table owns its own cells
+ const pencil = e.target?.closest ? e.target.closest("[data-juneau-edit]") : null;
+ if (!pencil || pencil.disabled || pencil.hidden) return;
+ e.preventDefault();
+ e.stopPropagation();
+ startInlineEdit(pencil, table, ctx, viewDef);
+ });
+ }
+
/**
* ONE delegated click listener on the stable table element. Survives DataTables redraw. Escape / outside-click
* dismissal is owned by the shared {@code popupLayerStack} (the popover registers as a light-dismiss layer on open),
@@ -4958,6 +5177,89 @@
return { el: el, refresh: refresh };
}
+ /** Confirm renderers registered by name (see {@link #registerConfirmRenderer}); prototype-less so a name is never inherited. */
+ const confirmRenderers = Object.create(null);
+
+ /** Dialog chromes registered by name (see {@link #registerDialogChrome}). */
+ const dialogChromes = Object.create(null);
+
+ /** Renderer names that always mean the built-in modal: the bulk path's historical "list" token, and "default". */
+ const BUILT_IN_CONFIRM_RENDERERS = { "default": 1, "list": 1 };
+
+ /** The page-default confirm renderer name, set by registering a renderer with {@code {asDefault:true}}. */
+ let defaultConfirmRendererName = null;
+
+ /**
+ * Registers a named confirm renderer: {@code fn(modal, request)} paints its own UI and returns a boolean (or a
+ * Promise of one) - true to proceed. Registering a name twice keeps the first and logs.
+ */
+ function registerConfirmRenderer(name, fn, opts) {
+ if (typeof fn !== "function" || isBlankToken(name) || Object.hasOwn(BUILT_IN_CONFIRM_RENDERERS, name)) {
+ console.error("[juneau-views] " + fmt("confirm renderer '%s' cannot be registered", name));
+ return;
+ }
+ if (confirmRenderers[name]) { console.error("[juneau-views] " + fmt("confirm renderer '%s' is already registered", name)); return; }
+ confirmRenderers[name] = fn;
+ if (opts?.asDefault) defaultConfirmRendererName = name;
+ }
+
+ /** Registers a named dialog chrome: {@code fn(parts, modal)} may restyle the header/footer parts of a dialog. */
+ function registerDialogChrome(name, fn) {
+ if (typeof fn !== "function" || isBlankToken(name)) {
+ console.error("[juneau-views] " + fmt("dialog chrome '%s' cannot be registered", name));
+ return;
+ }
+ if (dialogChromes[name]) { console.error("[juneau-views] " + fmt("dialog chrome '%s' is already registered", name)); return; }
+ dialogChromes[name] = fn;
+ }
+
+ /** One confirm list entry as text: a string verbatim, a row object by its label field then its id. */
+ function confirmItemLabel(item, request) {
+ if (typeof item === "string") return item;
+ const label = request?.labelField ? item?.[request.labelField] : null;
+ return String(label ?? item?.id ?? item?.[request?.rowIdField] ?? "");
+ }
+
+ /** The renderer-facing description of a confirm request (everything a custom renderer needs to paint it). */
+ function describeConfirm(request) {
+ return {
+ title: request.title || "",
+ body: request.body || null,
+ items: (request.items || []).map(function (it) { return confirmItemLabel(it, request); }),
+ confirmLabel: request.confirmLabel || "Confirm",
+ cancelLabel: request.cancelLabel || "Cancel",
+ tone: request.tone || "default"
+ };
+ }
+
+ /**
+ * The one confirm entry point (`JuneauViews.dialogs.confirm`): picks the renderer (request, then the action's
+ * `confirmRenderer`, then the page default, else the built-in modal) and resolves to whether the user confirmed.
+ * A request carrying a function `action` also has it invoked on confirm, which is the bulk path's contract.
+ * A renderer name that was never registered logs E-JS-71 and rejects without running anything.
+ */
+ function confirmPipeline(request) {
+ request = request || {};
+ const act = request.action;
+ const name = request.renderer || (act && typeof act === "object" ? act.confirmRenderer : null) || defaultConfirmRendererName;
+ const builtIn = !name || Object.hasOwn(BUILT_IN_CONFIRM_RENDERERS, name);
+ const render = builtIn ? null : confirmRenderers[name];
+ if (!builtIn && !render) {
+ console.error("[juneau-views] E-JS-71: " + fmt("confirm renderer '%s' is not registered", name));
+ return Promise.reject(new Error("E-JS-71"));
+ }
+ const modal = describeConfirm(request);
+ // The executor runs synchronously, so the built-in modal is on the layer stack by the time confirm() returns.
+ return new Promise(function (resolve) {
+ resolve(render ? render(modal, request) : buildFallbackConfirmModal(Object.assign({}, request, modal), null, null));
+ })
+ .then(function (ok) {
+ if (ok !== true) return false;
+ if (typeof act === "function" && render) act();
+ return true;
+ });
+ }
+
/**
* Gates a bulk action behind the mandatory confirm (an action opts out only with `confirm === false`). Prefers a
* host-provided `JuneauViews.dialogs.confirm(opts)` and otherwise falls back to the built-in modal below. The rows
@@ -4979,11 +5281,14 @@
confirmLabel: action.label || action.id,
tone: action.tone || "default",
rows: rows,
+ labelField: selectionState.labelField,
+ rowIdField: selectionState.rowIdField,
renderer: action.confirmRenderer || "list",
action: function () { runBulkActionDeferred(action, table, ctx, selectionState, ids); }
};
if (window.JuneauViews?.dialogs?.confirm) {
- window.JuneauViews.dialogs.confirm(opts);
+ const pending = window.JuneauViews.dialogs.confirm(opts);
+ if (typeof pending?.catch === "function") pending.catch(function () { /* already logged by the pipeline */ }); // NOSONAR javascript:S2486 -- a rejected confirm (unregistered renderer) is logged where it is raised
return;
}
buildFallbackConfirmModal(opts, table, selectionState);
@@ -4999,61 +5304,68 @@
* Lists up to 20 selected rows by `labelField` (falling back to the row id) and summarises the remainder.
*/
function buildFallbackConfirmModal(opts, table, selectionState) { // NOSONAR javascript:S1172 -- table kept for call-site symmetry with the dialogs.confirm path.
- const backdrop = document.createElement("div");
- backdrop.className = "juneau-view-dialog-backdrop juneau-view-confirm-modal juneau-view-confirm-modal-" + opts.tone;
- const dialog = document.createElement("div");
- dialog.className = "juneau-view-dialog";
- dialog.setAttribute("role", "dialog");
- dialog.setAttribute("aria-modal", "true");
-
- const title = document.createElement("h2");
- title.className = "juneau-view-dialog-title";
- title.textContent = opts.title;
- dialog.appendChild(title);
-
- if (opts.body) {
- const body = document.createElement("p");
- body.textContent = opts.body;
- dialog.appendChild(body);
- }
-
- const cap = 20;
- const list = document.createElement("ul");
- list.className = "juneau-view-confirm-list";
- opts.items.slice(0, cap).forEach(function (row) {
- const li = document.createElement("li");
- const label = selectionState.labelField ? row[selectionState.labelField] : null;
- li.textContent = String(label != null ? label : (row.id ?? row[selectionState.rowIdField]));
- list.appendChild(li);
- });
- if (opts.items.length > cap) {
- const more = document.createElement("li");
- more.textContent = "\u2026and " + (opts.items.length - cap) + " more";
- list.appendChild(more);
- }
- dialog.appendChild(list);
+ return new Promise(function (resolve) {
+ let settled = false;
+ function settle(ok) { if (!settled) { settled = true; resolve(ok); } }
+ const backdrop = document.createElement("div");
+ backdrop.className = "juneau-view-dialog-backdrop juneau-view-confirm-modal juneau-view-confirm-modal-" + opts.tone;
+ const dialog = document.createElement("div");
+ dialog.className = "juneau-view-dialog";
+ dialog.setAttribute("role", "dialog");
+ dialog.setAttribute("aria-modal", "true");
+
+ const title = document.createElement("h2");
+ title.className = "juneau-view-dialog-title";
+ title.textContent = opts.title;
+ dialog.appendChild(title);
+
+ if (opts.body) {
+ const body = document.createElement("p");
+ body.textContent = opts.body;
+ dialog.appendChild(body);
+ }
- const actions = document.createElement("div");
- actions.className = "juneau-view-dialog-actions";
- const cancelBtn = document.createElement("button");
- cancelBtn.type = "button";
- cancelBtn.className = "juneau-view-dialog-cancel";
- cancelBtn.textContent = "Cancel";
- cancelBtn.addEventListener("click", function () { popLayer(backdrop); });
- const confirmBtn = document.createElement("button");
- confirmBtn.type = "button";
- confirmBtn.className = "juneau-view-dialog-confirm";
- confirmBtn.textContent = opts.confirmLabel;
- confirmBtn.addEventListener("click", function () {
- popLayer(backdrop);
- opts.action();
+ const cap = 20;
+ const items = opts.items || [];
+ const list = document.createElement("ul");
+ list.className = "juneau-view-confirm-list";
+ items.slice(0, cap).forEach(function (row) {
+ const li = document.createElement("li");
+ li.textContent = typeof row === "string" ? row : confirmItemLabel(row, selectionState || opts);
+ list.appendChild(li);
+ });
+ if (items.length > cap) {
+ const more = document.createElement("li");
+ more.textContent = "\u2026and " + (items.length - cap) + " more";
+ list.appendChild(more);
+ }
+ if (items.length) dialog.appendChild(list);
+
+ const actions = document.createElement("div");
+ actions.className = "juneau-view-dialog-actions";
+ const cancelBtn = document.createElement("button");
+ cancelBtn.type = "button";
+ cancelBtn.className = "juneau-view-dialog-cancel";
+ cancelBtn.textContent = opts.cancelLabel || "Cancel";
+ cancelBtn.addEventListener("click", function () { settle(false); popLayer(backdrop); });
+ const confirmBtn = document.createElement("button");
+ confirmBtn.type = "button";
+ confirmBtn.className = "juneau-view-dialog-confirm" + (opts.tone === "danger" ? " juneau-view-dialog-confirm-danger" : "");
+ confirmBtn.textContent = opts.confirmLabel;
+ confirmBtn.addEventListener("click", function () {
+ settle(true);
+ popLayer(backdrop);
+ if (typeof opts.action === "function") opts.action();
+ });
+ actions.appendChild(cancelBtn);
+ actions.appendChild(confirmBtn);
+ dialog.appendChild(actions);
+
+ backdrop.appendChild(dialog);
+ // Cancel precedes Confirm in tab order, so it is the first focus target; a danger confirm makes that explicit.
+ pushLayer(backdrop, { kind: "dialog", trapFocus: true, lightDismiss: false, detachOnPop: true, onDismiss: function () { settle(false); } });
+ if (opts.tone === "danger" && typeof cancelBtn.focus === "function") cancelBtn.focus();
});
- actions.appendChild(cancelBtn);
- actions.appendChild(confirmBtn);
- dialog.appendChild(actions);
-
- backdrop.appendChild(dialog);
- pushLayer(backdrop, { kind: "dialog", trapFocus: true, lightDismiss: false, detachOnPop: true, onDismiss: null });
}
/** Max per-row bulk writes in flight at once - enough to be quick, few enough not to flood a shared backend. */
@@ -5268,6 +5580,39 @@
});
}
+ /**
+ * Like {@link submitRowAction}, but resolves to the outcome instead of painting it into the row: `{outcome, message,
+ * result}` where `outcome` is a normalized outcome token ("success", "failure", "refusal", "unknown", ...). The
+ * caller owns showing a failure and applying a success (see applySuccessBehavior). Used by the inline cell editor,
+ * which reports a failure inside the cell it is editing rather than in the row's action banner.
+ */
+ function submitRowActionForOutcome(action, table, tr, ctx, extra) {
+ const rowId = tr?.getAttribute ? tr.getAttribute(ROW_ID_ATTR) : null;
+ const req = buildActionRequest(
+ action, resolveCsrfToken(table), resolveCsrfHeaderName(table),
+ Object.assign(rowId == null ? {} : { targetId: rowId }, extra), rowDataForTr(ctx, tr));
+ if (req.refuse) return Promise.resolve({ outcome: "refusal", message: actionRefusalMessage(req.reason) });
+ setRowInFlight(tr, true);
+ return fetch(req.url, { method: req.method, headers: req.headers, body: req.body, credentials: "same-origin" })
+ .then(function (resp) {
+ setRowInFlight(tr, false);
+ if (!resp.ok) {
+ const boundary = (typeof resp.headers?.get === "function") ? resp.headers.get("X-Loopback-Boundary") : null;
+ return readBodyText(resp).then(function (text) {
+ return { outcome: "refusal", message: transportRefusal(resp.status, boundary, parseJsonSafe(text)).message };
+ });
+ }
+ return readBodyText(resp).then(function (text) {
+ const result = parseActionResult(text);
+ if (!result) return { outcome: "success", result: null };
+ return { outcome: normalizeOutcome(result), message: result.message, result: result };
+ });
+ }, function () {
+ setRowInFlight(tr, false);
+ return { outcome: "unknown", message: REQUEST_UNCONFIRMED_NOTICE };
+ });
+ }
+
/**
* Whether `table` currently has a row marked in-flight (design doc §9.1 B5) - see hasInFlightRow above; this is
* the setter half. Marks the `<tr>` in-flight for an OUTSTANDING SYNCHRONOUS WRITE and disables its action
@@ -5755,6 +6100,42 @@
* and in the ribbon-anchored host when there is not. A ribbon click otherwise died here, on an open FAILURE,
* before the submit path was ever reached.
*/
+ /**
+ * Fills a single-row `confirm` prompt's `{field}` tokens from the row (plain text, unlike the URL-escaped endpoint
+ * substitution). A token whose row value is absent or blank refuses, as it does for `endpoint`.
+ */
+ function resolveConfirmText(text, rowData) {
+ if (typeof text !== "string" || !ROW_ACTION_TOKEN_RE.test(text)) return { text: text };
+ if (hasBlankSubstitution(text, rowData)) return { refuse: true, reason: "empty-substitution" };
+ return { text: text.replace(new RegExp(ROW_ACTION_TOKEN_RE, "g"), function (m, key) { return String(rowData[key]); }) }; // NOSONAR javascript:S5852 -- ROW_ACTION_TOKEN_RE is linear ([^}] cannot match the closing brace)
+ }
+
+ /**
+ * The confirm-only (no form URL) dialog. The prompt is the action's `confirm` text with its `{field}` tokens
+ * filled, else its name. An action naming a `confirmRenderer` goes through the confirm pipeline and submits only
+ * on a true answer; every other action keeps the built-in dialog, carrying `confirmLabel` and `tone`.
+ */
+ function openConfirmOnlyDialog(action, table, tr, ctx) {
+ const resolved = resolveConfirmText(action.confirm, rowDataForTr(ctx, tr));
+ if (resolved.refuse) {
+ renderActionRefusalFor(tr, action, resolved.reason, ctx);
+ return;
+ }
+ const modal = { title: resolved.text || actionDisplayName(action) };
+ if (isBlankToken(action.confirmRenderer)) {
+ showActionDialog(modal, action, table, tr, ctx);
+ return;
+ }
+ confirmPipeline({
+ title: modal.title,
+ confirmLabel: action.confirmLabel || action.label || "Confirm",
+ tone: action.tone,
+ action: action
+ }).then(function (ok) {
+ if (ok) submitActionDialog(modal, action, table, tr, ctx, null);
+ }).catch(function () { /* already logged by the pipeline; the action does not run */ }); // NOSONAR javascript:S2486 -- the unregistered-renderer rejection is logged where it is raised
+ }
+
// NOSONAR javascript:S3776 -- encodes a views/widgets state machine; complexity is inherent.
function openActionDialog(action, table, tr, ctx) {
// v1 depth cap (counts dialog-kind layers): a third dialog is a visible refusal inside the current top dialog.
@@ -5763,7 +6144,7 @@
// Confirm-only LOCAL path (no form-source URL): unversioned, and never fail-loud on a missing version.
// The name read is widened (label, then title, then id) so a ribbon-hosted action - which carries
// `title` and no `label` - prompts with its own name rather than its raw id.
- showActionDialog({ title: action.confirm || actionDisplayName(action) }, action, table, tr, ctx);
+ openConfirmOnlyDialog(action, table, tr, ctx);
return;
}
const rowData = rowDataForTr(ctx, tr);
@@ -6003,7 +6384,11 @@
const confirmBtn = document.createElement("button");
confirmBtn.type = "button";
confirmBtn.className = "juneau-view-dialog-confirm";
- confirmBtn.textContent = action?.label || "Confirm";
+ confirmBtn.textContent = modal?.confirmLabel || action?.confirmLabel || action?.label || "Confirm";
+ if ((modal?.tone || action?.tone) === "danger") {
+ confirmBtn.classList.add("juneau-view-dialog-confirm-danger");
+ dialog.classList.add("juneau-view-dialog-danger");
+ }
actions.appendChild(cancelBtn);
actions.appendChild(confirmBtn);
dialog.appendChild(actions);
@@ -6110,8 +6495,10 @@
// signal to its author today. (4) A miss in both stays today's fail-closed disabled+marked paint.
if (dialogActionIsOpenable(ctx, actionId)) {
const target = findRowActionById(ctx?.viewDef, actionId);
- const failing = firstFailingRowActionRule(target, rowDataForTr(ctx, tr));
- if (failing) disableRowActionControl(control, failing.reason);
+ const rowData = rowDataForTr(ctx, tr);
+ const failing = firstFailingRowActionRule(target, rowData);
+ if (!isRowActionVisible(target, rowData)) control.hidden = true; // visibleWhen: irrelevant for this row
+ else if (failing) disableRowActionControl(control, failing.reason);
} else if (rowActionIdExists(ctx, actionId) || ! childActionById(childCatalog, actionId)) {
control.disabled = true;
control.setAttribute("aria-disabled", "true");
@@ -6228,7 +6615,9 @@
let target = null;
const catalog = (ctx?.viewDef?.rowActions) || [];
for (const c of catalog) if (c?.id === actionId) { target = c; break; }
- const failing = firstFailingRowActionRule(target, rowDataForTr(ctx, tr));
+ const rowData = rowDataForTr(ctx, tr);
+ if (!isRowActionVisible(target, rowData)) { renderDialogActionRefusal(actionId); return; }
+ const failing = firstFailingRowActionRule(target, rowData);
if (failing) { renderDialogActionRefusal(actionId, failing.reason); return; }
openActionDialog(target, table, tr, ctx);
}
@@ -6553,6 +6942,115 @@
return out;
}
+ /** Hands a registered dialog chrome the header/footer parts of a freshly built dialog; a throwing chrome is logged, not fatal. */
+ function applyDialogChrome(modal, ui) {
+ const parts = {
+ dialog: ui.dialog,
+ header: ui.dialog.querySelector(".juneau-view-dialog-header"),
+ actions: ui.dialog.querySelector(".juneau-view-dialog-actions"),
+ confirmBtn: ui.confirmBtn,
+ cancelBtn: ui.cancelBtn,
+ dismissBtn: ui.dismissBtn
+ };
+ try { dialogChromes[modal.chrome](parts, modal); }
+ catch (e) { console.error("[juneau-views] " + fmt("dialog chrome '%s' threw: %s", modal.chrome, e?.message ?? e)); } // NOSONAR javascript:S2486 -- a throwing chrome must not stop the dialog opening
+ }
+
+ /** Paints one wizard step's display fields into `host` (textContent only). */
+ function paintStepFields(host, step) {
+ const dl = document.createElement("dl");
+ dl.className = "juneau-view-dialog-fields";
+ (step.fields || []).forEach(function (f) {
+ const dt = document.createElement("dt");
+ dt.textContent = (f?.label != null) ? String(f.label) : "";
+ const dd = document.createElement("dd");
+ buildModalFieldValueNode(dd, f);
+ dl.appendChild(dt);
+ dl.appendChild(dd);
+ });
+ host.appendChild(dl);
+ }
+
+ /**
+ * Turns the dialog `ui` into the scripting handle (`dialogs.open`'s return value) and returns the private state the
+ * confirm wiring needs. Steps (`modal.steps`) render one at a time with Back and Next; the last step's button is
+ * the dialog's own confirm. A script drives dynamic flows through `setStep` / `setConfirmLabel` / `onConfirm`.
+ */
+ // NOSONAR javascript:S3776 -- encodes a views/widgets state machine; complexity is inherent.
+ function wireDialogHandle(ui, modal, action, table, tr, ctx) { // NOSONAR javascript:S107 -- dialog-build context threaded through unchanged
+ const steps = Array.isArray(modal?.steps) ? modal.steps.filter(function (st) { return st && st.id != null; }) : [];
+ const finalLabel = ui.confirmBtn.textContent;
+ const state = { confirmHandler: null, cancelHandler: null, submitted: false, proceed: null };
+ let stepIndex = steps.length ? 0 : -1;
+ let freeStep = null;
+ let closedResolve = null;
+ const closed = new Promise(function (resolve) { closedResolve = resolve; });
+ let backBtn = null;
+ if (steps.length) {
+ backBtn = document.createElement("button");
+ backBtn.type = "button";
+ backBtn.className = "juneau-view-dialog-back";
+ backBtn.textContent = "Back";
+ backBtn.hidden = true;
+ ui.cancelBtn.parentNode.insertBefore(backBtn, ui.confirmBtn);
+ backBtn.addEventListener("click", function () { if (stepIndex > 0) { stepIndex--; renderStep(); } });
+ }
+ let bodyEl = null;
+
+ function body() {
+ if (!bodyEl) {
+ bodyEl = document.createElement("div");
+ bodyEl.className = "juneau-view-dialog-body";
+ ui.dialog.insertBefore(bodyEl, ui.cancelBtn.parentNode);
+ }
+ return bodyEl;
+ }
+
+ function renderStep() {
+ const st = steps[stepIndex];
+ const host = body();
+ while (host.firstChild) host.removeChild(host.firstChild);
+ paintStepFields(host, st);
+ const titleEl = ui.dialog.querySelector(".juneau-view-dialog-title");
+ if (titleEl && st.title != null) titleEl.textContent = String(st.title);
+ const last = stepIndex === steps.length - 1;
+ ui.confirmBtn.textContent = st.confirmLabel != null ? String(st.confirmLabel) : (last ? finalLabel : "Next");
+ backBtn.hidden = stepIndex === 0;
+ }
+
+ if (steps.length) renderStep();
+
+ state.nextStep = function () {
+ if (stepIndex < 0 || stepIndex >= steps.length - 1) return null;
+ return function () { stepIndex++; renderStep(); };
+ };
+ state.dismissed = function () {
+ closedResolve(state.submitted ? "submitted" : "cancelled");
+ if (!state.submitted && state.cancelHandler) state.cancelHandler();
+ };
+
+ ui.fields = function () { return collectDialogFormFields(ui.dialog); };
+ ui.body = body;
+ ui.step = function () { return stepIndex >= 0 ? steps[stepIndex].id : freeStep; };
+ ui.setStep = function (id) {
+ const i = steps.findIndex(function (st) { return st.id === id; });
+ if (i >= 0) { stepIndex = i; renderStep(); return; }
+ if (steps.length) { console.error("[juneau-views] " + fmt("dialog step '%s' is not defined", id)); return; }
+ freeStep = id;
+ };
+ ui.setConfirmLabel = function (label) { ui.confirmBtn.textContent = label == null ? "" : String(label); };
+ ui.setCancelLabel = function (label) { ui.cancelBtn.textContent = label == null ? "" : String(label); };
+ ui.onConfirm = function (fn) { state.confirmHandler = typeof fn === "function" ? fn : null; };
+ ui.onCancel = function (fn) { state.cancelHandler = typeof fn === "function" ? fn : null; };
+ ui.submit = function (extra) {
+ state.proceed(extra?.fields ?? collectDialogFormFields(ui.dialog));
+ return Promise.resolve({});
+ };
+ ui.close = function () { popLayer(ui.backdrop); }; // pops this dialog's layer (and anything stacked above it), never a sibling below
+ ui.closed = closed;
+ return state;
+ }
+
/**
* Shows a dialog overlay for an action as a modal layer on the shared {@code popupLayerStack} and wires its confirm
* (validate -> submit) / cancel (dismiss) buttons. The backdrop is pushed as a {@code kind:"dialog"} focus-trapping
@@ -6563,15 +7061,19 @@
*/
// NOSONAR javascript:S3776 -- encodes a views/widgets state machine; complexity is inherent.
function showActionDialog(modal, action, table, tr, ctx) {
+ if (modal?.chrome && !dialogChromes[modal.chrome]) {
+ console.error("[juneau-views] E-JS-72: " + fmt("dialog chrome '%s' is not registered", modal.chrome));
+ return null;
+ }
if (dialogLayerCount() >= MAX_DIALOG_DEPTH) { renderDialogDepthRefusal(); return null; }
const seq = ++dialogSeq;
const ui = buildDialogOverlay(modal, action, table, tr, ctx, seq);
+ if (modal?.chrome) applyDialogChrome(modal, ui);
+ const state = wireDialogHandle(ui, modal, action, table, tr, ctx);
function close() { popLayer(ui.backdrop); }
ui.cancelBtn.addEventListener("click", close);
if (ui.dismissBtn) ui.dismissBtn.addEventListener("click", close);
- ui.confirmBtn.addEventListener("click", function () {
- if (! validateDialogForm(ui.dialog, true)) return; // fail-loud client validation before the submit
- const fields = collectDialogFormFields(ui.dialog);
+ function proceed(fields) {
// A modal that opted in to hosting its own result KEEPS ITS LAYER and registers a hold instead of
// closing; the settle then decides that layer's fate from the outcome. Registration happens BEFORE the
// submit because the submit's client-refusal terminal can settle synchronously. The hold is passed
@@ -6579,10 +7081,23 @@
// J0512's targetId precedence and its tripwire keep covering the only submit path there is.
// No ctx means no hold registry AND no dialog stack to check liveness against, so it closes as today -
// the fail-safe direction: today's behavior, not a dialog nothing can ever settle.
+ state.submitted = true;
if (modal?.keepOpenOnSubmit && ctx) beginDialogResultHold(ui, action, modal, ctx);
else close();
submitActionDialog(modal, action, table, tr, ctx, fields);
+ }
+ ui.confirmBtn.addEventListener("click", function () {
+ if (! validateDialogForm(ui.dialog, true)) return; // fail-loud client validation before the submit
+ const fields = collectDialogFormFields(ui.dialog);
+ const advance = state.nextStep(); // null when there is nothing to advance to (no steps, or on the last)
+ function go() { if (advance) advance(); else proceed(fields); }
+ if (! state.confirmHandler) { go(); return; }
+ // onConfirm decides: exactly `true` (or a Promise of it) continues, anything else keeps the dialog as-is.
+ const verdict = state.confirmHandler(fields);
+ if (typeof verdict?.then === "function") verdict.then(function (ok) { if (ok === true) go(); });
+ else if (verdict === true) go();
});
+ state.proceed = proceed;
if (ctx) {
if (! ctx._dialogStack) ctx._dialogStack = [];
ctx._dialogStack.push(ui.backdrop);
@@ -6598,8 +7113,10 @@
ctx._actionDialog = ctx._dialogStack.at(-1) ?? null;
}
notifyPollPausedChange(ctx);
+ state.dismissed();
}
});
+ if ((modal?.tone || action?.tone) === "danger" && typeof ui.cancelBtn.focus === "function") ui.cancelBtn.focus();
notifyPollPausedChange(ctx);
// Enhance-on-insert for a dialog-hosted bar slot: MUST run after pushLayer has portalled the dialog into the
// document (initAll() scans document-wide), reusing the SAME chrome entry + shared wired marker the
@@ -7126,6 +7643,7 @@
menu.dataset.testid = "action-menu";
const rowData = rowDataForTr(ctx, tr);
(viewDef.rowActions || []).forEach(function (action) {
+ if (!isRowActionVisible(action, rowData)) return; // hidden for this row by visibleWhen
const li = document.createElement("li");
li.setAttribute("role", "none");
const item = document.createElement("button");
@@ -7140,7 +7658,8 @@
item.addEventListener("click", function () {
// Fresh, fail-closed re-check: a gated-and-failing action must never fire, even if its disabled
// state was somehow bypassed or the menu is stale.
- if (firstFailingRowActionRule(action, rowDataForTr(ctx, tr))) return;
+ const freshRow = rowDataForTr(ctx, tr);
+ if (!isRowActionVisible(action, freshRow) || firstFailingRowActionRule(action, freshRow)) return;
closeRowActionMenus(table);
// A present=dialog action opens the modal (confirmation + optional form) before its submit;
// everything else is the direct fail-closed submit.
@@ -7194,7 +7713,9 @@
const tr = pill.closest ? pill.closest("tr") : null;
if (!tr) return;
if (tr.dataset?.juneauInflight) return; // in-flight guard (no double submit)
- if (firstFailingRowActionRule(action, rowDataForTr(ctx, tr))) return;
+ const rowData = rowDataForTr(ctx, tr);
+ if (!isRowActionVisible(action, rowData)) return; // a hidden action never fires, even if the pill was un-hidden
+ if (firstFailingRowActionRule(action, rowData)) return;
if (isDialogAction(action)) openActionDialog(action, table, tr, ctx);
else submitRowAction(action, table, tr, ctx);
}
@@ -7408,6 +7929,7 @@
const field = ctx.selectionState?.rowIdField ?? null;
stampRowId(rowEl, rowData, field);
applyRowActionPillGates(rowEl, rowData, viewDef);
+ applyInlineEditGates(rowEl, rowData, viewDef);
};
opts.order = resolveOrder(viewDef, opts.columns);
}
@@ -7737,7 +8259,78 @@
sort = { column: String(def.data), dir: String(dir) };
}
}
- return { tab: readLiveShareTab(), filters: filters, sort: sort };
+ return { tab: readLiveShareTab(), filters: filters, sort: sort, ext: Object.assign({}, ctx?.urlExt) };
+ }
+
+ /**
+ * Registers the built-in directives a view's {@code urlState} config names and seeds {@code ctx.urlExt} from the
+ * address bar, so the FIRST data request already carries the deep-linked params. Primary table only; idempotent.
+ */
+ function prepareUrlStateDirectives(table, viewDef, ctx) {
+ const U = NS.urlState;
+ if (!U || typeof U.registerDirective !== "function" || ctx._urlDirectivesPrepared) return;
+ if (!viewDef?.urlState || !isShareablePrimaryTable(table, viewDef)) return;
+ ctx._urlDirectivesPrepared = true;
+ for (const name of Object.keys(viewDef.urlState)) {
+ const factory = U.builtins?.[name];
+ if (typeof factory !== "function" || U.hasDirective(name)) continue;
+ U.registerDirective(name, factory(viewDef.urlState[name] || {}));
+ }
+ const fromUrl = typeof U.readFromSearch === "function" ? U.readFromSearch(window.location.search) : null;
+ ctx.urlExt = Object.assign({}, fromUrl?.ext);
+ announceUrlStateStatus(table, ctx);
+ }
+
+ /** Merges every registered directive's {@code toParams} over the live {@code ctx.urlExt}. */
+ function urlStateRequestParams(ctx) {
+ const out = {};
+ const U = NS.urlState;
+ for (const name of Object.keys(ctx?.urlExt || {})) {
+ const codec = U?.directiveCodec?.(name);
+ if (typeof codec?.toParams === "function") Object.assign(out, codec.toParams(ctx.urlExt[name]));
+ }
+ return out;
+ }
+
+ /** Announces a directive's {@code statusMessage} (e.g. the {@code ids} cap) through the table's live region. */
+ function announceUrlStateStatus(table, ctx) {
+ const U = NS.urlState;
+ for (const name of Object.keys(ctx?.urlExt || {})) {
+ const msg = U?.directiveCodec?.(name)?.statusMessage?.(ctx.urlExt[name]);
+ if (msg) announce(table, msg);
+ }
+ }
+
+ /** Runs each registered directive's {@code apply(value, table)} against the current {@code ctx.urlExt}. */
+ function applyUrlStateDirectives(table, ctx) {
+ const U = NS.urlState;
+ for (const name of Object.keys(ctx?.urlExt || {})) {
+ const codec = U?.directiveCodec?.(name);
+ if (typeof codec?.apply !== "function") continue;
+ try { codec.apply(ctx.urlExt[name], table); } catch (e) { error("[juneau-views] E-JS-68: url-state directive '" + name + "' apply failed: " + (e?.message || e)); }
+ }
+ }
+
+ /**
+ * Sets (or, with a null/empty value, clears) one registered {@code ?state=} directive on a live table: updates the
+ * address bar (replaceState, honoring clean-address) and refetches with the new request params.
+ *
+ * @example
+ * NS.urlState.setDirective(tableEl, "window", { start: "2026-10-01T00:00:00Z", end: "2026-10-02T00:00:00Z" });
+ */
+ function setUrlStateDirective(tableEl, name, value) {
+ const ctx = tableEl?.__juneauCtx;
+ const U = NS.urlState;
+ if (!ctx || !U?.hasDirective?.(name)) {
+ error("[juneau-views] E-JS-67: url-state directive '" + name + "' is not registered for this table");
+ return false;
+ }
+ ctx.urlExt = ctx.urlExt || {};
+ if (value == null) delete ctx.urlExt[name]; else ctx.urlExt[name] = value;
+ syncShareableUrlState(tableEl, ctx);
+ announceUrlStateStatus(tableEl, ctx);
+ if (ctx.dataTable) reloadTableData(ctx.dataTable);
+ return true;
}
/**
@@ -7930,6 +8523,17 @@
}
// Reflect the post-open state once (skipped under clean-address).
syncShareableUrlState(table, ctx);
+ applyUrlStateDirectives(table, ctx);
+ // Back / Forward: re-read the registered directives from the address bar, re-apply them, and refetch.
+ if (U.directiveNames?.().length && typeof window.addEventListener === "function") {
+ window.addEventListener("popstate", function () {
+ const st = U.readFromSearch(window.location.search);
+ ctx.urlExt = Object.assign({}, st?.ext);
+ announceUrlStateStatus(table, ctx);
+ applyUrlStateDirectives(table, ctx);
+ if (ctx.dataTable) reloadTableData(ctx.dataTable);
+ });
+ }
}
/**
@@ -7996,8 +8600,11 @@
// buildOptions merges no parent-scope parameter.
nestedScope: ctx.nestedScope,
// The view table, so a server-mode data request can carry the CSRF token (read per request).
- table: table
+ table: table,
+ // Request params contributed by registered ?state= directives (window / ids), read at REQUEST time.
+ urlStateParams: function () { return urlStateRequestParams(ctx); }
};
+ prepareUrlStateDirectives(table, viewDef, ctx);
const opts = buildOptions(viewDef, deps);
assembleFullColumnArray(opts, viewDef, ctx);
@@ -8250,6 +8857,7 @@
if (findRowDetailTemplate(table))
initDetailsExpander(table, ctx, viewDef);
initCellPopover(table, ctx, viewDef);
+ initInlineEdit(table, ctx, viewDef);
if (viewDef.rowActions?.length)
initRowActions(table, viewDef, ctx);
if (selectionState)
@@ -8399,6 +9007,7 @@
function initTableWidgets(table, ctx, viewDef) {
if (findRowDetailTemplate(table)) initDetailsExpander(table, ctx, viewDef);
initCellPopover(table, ctx, viewDef);
+ initInlineEdit(table, ctx, viewDef);
if (viewDef.rowActions?.length) initRowActions(table, viewDef, ctx);
}
@@ -9243,6 +9852,10 @@
mergeMeta: mergeMeta,
buildOptions: buildOptions,
initAll: initAll,
+ // ?state= directive hook behind NS.urlState.setDirective (the codec module has no live table).
+ setUrlStateDirective: setUrlStateDirective,
+ urlStateRequestParams: urlStateRequestParams,
+ prepareUrlStateDirectives: prepareUrlStateDirectives,
// Previously private - exposed so a host (HTML-slot `regions.mount`, or any lazy panel) can init one
// specific view's table on demand. Always returns a thenable; overlapping calls coalesce on the in-flight
// promise (data-juneau-init-pending). Already idempotent (isDataTable guard), so re-entry after the
@@ -9368,6 +9981,8 @@
ownRowsWithId: ownRowsWithId,
fillCellPopover: fillCellPopover,
initCellPopover: initCellPopover,
+ initInlineEdit: initInlineEdit,
+ applyInlineEditGates: applyInlineEditGates,
closeCellPopover: closeCellPopover,
appendPopoverTrigger: appendPopoverTrigger,
// Row actions + fail-closed CSRF submit - exposed for manual verification and the fail-closed canary.
@@ -9496,6 +10111,147 @@
renderAsyncStatus: renderAsyncStatus
};
+ // ==================================================================================================================
+ // SANCTIONED HOOKS - thin wrappers over the internals above. Every namespace here is frozen; nothing outside one
+ // of these namespaces (or NS.init, which is deprecated) is public API.
+ // ==================================================================================================================
+
+ /** Substitutes each {@code %s} in {@code msg} with the next argument. */
+ function fmt(msg, ...args) {
+ let i = 0;
+ return String(msg).replaceAll("%s", () => String(args[i++]));
+ }
+
+ /**
+ * Resolves the row context (table, tr, row, viewDef) for any element inside a view table row or its row-detail
+ * panel. Walks up via owningViewTable first; when that misses (the element lives inside a portalled row-detail
+ * panel), falls back to the panel's _juneauParentTr back-reference.
+ */
+ function hookContextOf(el) {
+ if (!el) return null;
+ let table = owningViewTable(el);
+ let tr = null;
+ if (table) {
+ tr = typeof el.closest === "function" ? el.closest("tbody tr") : null;
+ } else {
+ const panel = typeof el.closest === "function" ? el.closest("[data-juneau-row-detail]") : null;
+ tr = panel?._juneauParentTr || null;
+ table = tr ? owningViewTable(tr) : null;
+ }
+ if (!table || !tr) return null;
+ const ctx = table.__juneauCtx;
+ if (!ctx) return null;
+ return { table: table, tr: tr, row: rowDataForTr(ctx, tr) || {}, viewDef: ctx.viewDef || {} };
+ }
+
+ /** The table-handle shape returned by {@code tables.ready} / {@code tables.handle}. */
+ function hookTableHandleFor(el) {
+ const ctx = el.__juneauCtx;
+ return {
+ element: el,
+ dataTable: ctx.dataTable,
+ viewDef: ctx.viewDef,
+ contextOf: function (tr) { return { table: el, tr: tr, row: rowDataForTr(ctx, tr) || {}, viewDef: ctx.viewDef }; }
+ };
+ }
+
+ /** The element a CSRF lookup should read: the owning view table of {@code el}, else {@code el} itself. */
+ function hookCsrfTable(el) {
+ return el ? (owningViewTable(el) || el) : null;
+ }
+
+ NS.rowActions = Object.freeze({
+ contextOf: hookContextOf,
+ find: function (viewDefOrEl, actionId) {
+ const viewDef = viewDefOrEl?.rowActions ? viewDefOrEl : (hookContextOf(viewDefOrEl) || {}).viewDef;
+ return findRowActionById(viewDef, actionId);
+ },
+ isDialog: isDialogAction,
+ run: function (el, actionId, opts) {
+ const rc = hookContextOf(el);
+ if (!rc) {
+ console.error("[juneau-views] " + fmt("rowActions.run: element is not inside a view table row"));
+ return Promise.reject(new Error("E-JS-74"));
+ }
+ const action = findRowActionById(rc.viewDef, actionId);
+ if (!action) {
+ console.error("[juneau-views] " + fmt("row action '%s' not found on view '%s'", actionId, rc.viewDef.id || ""));
+ return Promise.reject(new Error("E-JS-70"));
+ }
+ const ctx = rc.table.__juneauCtx;
+ if (isDialogAction(action)) return Promise.resolve(openActionDialog(action, rc.table, rc.tr, ctx));
+ return Promise.resolve(submitRowAction(action, rc.table, rc.tr, ctx, opts?.extra || {}));
+ },
+ open: function (action, rc) { return openActionDialog(action, rc.table, rc.tr, rc.table.__juneauCtx); },
+ submit: function (action, rc, extra) { return submitRowAction(action, rc.table, rc.tr, rc.table.__juneauCtx, extra || {}); },
+ submitForOutcome: function (action, rc, extra) { return submitRowActionForOutcome(action, rc.table, rc.tr, rc.table.__juneauCtx, extra || {}); },
+ registerConfirmRenderer: registerConfirmRenderer
+ });
+
+ NS.dialogs = Object.freeze({
+ // opts.action (a row action, or the id of one on the row's view) is what a confirming dialog submits; without it
+ // a confirm has nothing to send and the dialog is a pure prompt.
+ open: function (modal, rc, opts) {
+ const a = opts?.action;
+ const action = typeof a === "string" ? findRowActionById(rc.viewDef, a) : (a ?? null);
+ return showActionDialog(modal, action, rc.table, rc.tr, rc.table.__juneauCtx);
+ },
+ confirm: confirmPipeline,
+ registerChrome: registerDialogChrome,
+ depth: dialogLayerCount
+ });
+
+ NS.probeSelection = Object.freeze({
+ enhance: enhanceProbeGroup,
+ initAll: function (root) {
+ for (const g of ownNodes(root || document, null, "[data-juneau-probe-group]")) enhanceProbeGroup(g);
+ }
+ });
+
+ NS.tables = Object.freeze({
+ columnIndex: function (tableOrDt, dataKey) {
+ const dt = typeof tableOrDt?.row === "function" ? tableOrDt : tableOrDt?.__juneauCtx?.dataTable;
+ return liveDtIndex(dataKey, dt ? dt.columns().header().toArray() : null);
+ },
+ ready: function (el) {
+ return new Promise(function (resolve) {
+ (function poll() {
+ if (el.__juneauCtx?.dataTable) resolve(hookTableHandleFor(el));
+ else setTimeout(poll, 20);
+ })();
+ });
+ },
+ handle: function (el) { return el?.__juneauCtx ? hookTableHandleFor(el) : null; },
+ reload: function (el, opts) {
+ const dt = el?.__juneauCtx?.dataTable;
+ if (dt) dt.ajax.reload(null, opts?.resetPaging === true);
+ }
+ });
+
+ NS.status = Object.freeze({ render: renderAsyncStatus });
+ NS.csrf = Object.freeze({
+ token: function (el) { const t = hookCsrfTable(el); return t ? resolveCsrfToken(t) : null; },
+ headerName: function (el) { const t = hookCsrfTable(el); return t ? resolveCsrfHeaderName(t) : DEFAULT_CSRF_HEADER; },
+ headers: function (el) {
+ const t = hookCsrfTable(el);
+ const token = t ? resolveCsrfToken(t) : null;
+ if (isBlankToken(token)) return {};
+ return { [resolveCsrfHeaderName(t)]: token };
+ }
+ });
+ NS.rules = Object.freeze({
+ test: evaluateRules,
+ facts: pageFacts,
+ /** Tests `rules` against `row` merged with the page facts under `facts` (the row-level evaluation map). */
+ testRow: function (rules, row) { return evaluateRules(rules, rowRuleScope(row)); }
+ });
+ NS.details = Object.freeze({ fillSlots: fillDetailSlots });
+ NS.html = Object.freeze({
+ // NS._render (juneau-renders.js) is a sibling <script>; never assume load order.
+ esc: function (s) { return typeof NS._render?.escHtml === "function" ? NS._render.escHtml(s) : String(s ?? ""); },
+ escAttr: viewEscAttr
+ });
+
initCursorTooltip();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAll);
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/BadgeDef_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/BadgeDef_Test.java
new file mode 100644
index 0000000000..6e833225cb
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/BadgeDef_Test.java
@@ -0,0 +1,126 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.junit.jupiter.api.*;
+
+class BadgeDef_Test {
+
+ @Test void a01_minimal() {
+ var b = BadgeDef.create("pending").src("/rest/changes/pending");
+ b.validate();
+ var m = b.toContractMap();
+ assertEquals("pending", m.get("id"));
+ assertEquals("/rest/changes/pending", m.get("src"));
+ assertFalse(m.containsKey("placement"));
+ assertFalse(m.containsKey("tone"));
+ }
+
+ @Test void a02_fullExample_matchesJavadoc() {
+ var b = BadgeDef.create("pending")
+ .src("/rest/changes/pending")
+ .scope("beanType", BadgeDef.ScopeBy.NAV, Map.of("suspensions", List.of("Suspension")))
+ .href("/ui/changes")
+ .stateFilter("beanType")
+ .refreshes("rules-table")
+ .refreshMs(60_000);
+ b.validate();
+ var m = b.toContractMap();
+ assertEquals("/ui/changes", m.get("href"));
+ assertEquals(60_000L, m.get("refreshMs"));
+ assertEquals(List.of("rules-table"), m.get("refreshes"));
+ @SuppressWarnings("unchecked")
+ var scope = (Map<String,Object>) m.get("scope");
+ assertEquals("beanType", scope.get("param"));
+ assertEquals("nav", scope.get("by"));
+ @SuppressWarnings("unchecked")
+ var stateFilter = (Map<String,Object>) m.get("stateFilter");
+ assertEquals("beanType", stateFilter.get("column"));
+ }
+
+ @Test void a03_idRequired() {
+ var e = assertThrows(IllegalArgumentException.class, () -> BadgeDef.create(null));
+ assertTrue(e.getMessage().contains("id must not be null or blank"), e.getMessage());
+ var e2 = assertThrows(IllegalArgumentException.class, () -> BadgeDef.create(" "));
+ assertTrue(e2.getMessage().contains("id must not be null or blank"), e2.getMessage());
+ }
+
+ @Test void a04_e61_srcRequired() {
+ var e = assertThrows(IllegalArgumentException.class, () -> BadgeDef.create("pending").validate());
+ assertEquals("<@badge id='pending'> requires src=.", e.getMessage());
+ }
+
+ @Test void a05_e62_refreshMsFloor() {
+ var b = BadgeDef.create("pending").src("/x").refreshMs(1000);
+ var e = assertThrows(IllegalArgumentException.class, b::validate);
+ assertEquals("'pending' refreshMs='1000' is below the minimum '5000'.", e.getMessage());
+ }
+
+ @Test void a06_refreshMsAtFloorIsFine() {
+ BadgeDef.create("pending").src("/x").refreshMs(5000).validate();
+ }
+
+ @Test void a07_e66_toolbarRequiresTable() {
+ var b = BadgeDef.create("pending").src("/x").placement(BadgeDef.Placement.TOOLBAR);
+ var e = assertThrows(IllegalArgumentException.class, b::validate);
+ assertEquals("<@badge id='pending'> placement='toolbar' requires table=.", e.getMessage());
+ }
+
+ @Test void a08_toolbarWithTableIsFine() {
+ BadgeDef.create("pending").src("/x").placement(BadgeDef.Placement.TOOLBAR).table("rules-table").validate();
+ }
+
+ @Test void a09_r7_scopeByViewRequiresTable_uncoded() {
+ var b = BadgeDef.create("pending").src("/x").scope("beanType", BadgeDef.ScopeBy.VIEW, null);
+ var e = assertThrows(IllegalArgumentException.class, b::validate);
+ assertTrue(e.getMessage().contains("scope.by='view' requires table"), e.getMessage());
+ }
+
+ @Test void a10_scopeByViewWithTableIsFine() {
+ BadgeDef.create("pending").src("/x").table("rules-table")
+ .scope("beanType", BadgeDef.ScopeBy.VIEW, null).validate();
+ }
+
+ @Test void a11_labelToneTooltipMax_roundTrip() {
+ var m = BadgeDef.create("pending").src("/x")
+ .label(Map.of("one", "{total} item", "other", "{total} items"))
+ .tone(BadgeDef.Tone.DANGER)
+ .tooltipMax(5)
+ .toContractMap();
+ @SuppressWarnings("unchecked")
+ var label = (Map<String,Object>) m.get("label");
+ assertEquals("{total} item", label.get("one"));
+ assertEquals("danger", m.get("tone"));
+ assertEquals(5, m.get("tooltipMax"));
+ }
+
+ @Test void a12_visibleWhen_roundTrip() {
+ var m = BadgeDef.create("pending").src("/x")
+ .visibleWhen(VisibilityRule.when("facts.viewer.roles").contains("oncall"))
+ .toContractMap();
+ @SuppressWarnings("unchecked")
+ var vw = (List<Map<String,Object>>) m.get("visibleWhen");
+ assertEquals(1, vw.size());
+ assertEquals("facts.viewer.roles", vw.get(0).get("field"));
+ assertEquals("contains", vw.get(0).get("op"));
+ assertEquals("oncall", vw.get(0).get("value"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConfirmRenderer_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConfirmRenderer_BrowserTest.java
new file mode 100644
index 0000000000..14cc4d370b
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConfirmRenderer_BrowserTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Runs the REAL served scripts in headless Chromium and asserts the confirm pipeline as a user would experience it: a
+ * registered renderer's own buttons decide whether the row action's write is sent, and a danger confirm focuses Cancel first.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does. The prober ({@code confirm-renderer.html.cjs}) is resolved by name from the profile's {@code juneau.jsTests.harnessDir}.
+ */
+@EnabledIfSystemProperty(named=ConfirmRenderer_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class ConfirmRenderer_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static Map<?,?> report;
+
+ private static String resource(String path) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harnessDir")).resolve("confirm-renderer.html.cjs");
+
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+ + resource(ViewsMixin.RENDERS_JS_RESOURCE) + "\n</script>\n<script>\n"
+ + resource(ViewsMixin.VIEWS_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("confirm-renderer.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ /** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("confirm-renderer-stdout.json");
+ var stderr = dir.resolve("confirm-renderer-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @Test void a01_runtimeLoadedWithoutScriptErrors() {
+ assertEquals(Boolean.TRUE, report.get("hasRun"), () -> report.toString());
+ assertEquals(List.of(), report.get("jsFailures"), () -> report.toString());
+ }
+
+ @Test void b01_customRendererPaintsAndConfirmSendsOneWrite() {
+ assertEquals("Delete Widget?", report.get("styledText"), () -> report.toString());
+ assertEquals("Delete it", report.get("styledOkLabel"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("styledVisible"), () -> report.toString());
+ assertEquals(0, ((Number)report.get("fetchesBeforeClick")).intValue(), () -> report.toString());
+ assertEquals(1, ((Number)report.get("confirmFetches")).intValue(), () -> report.toString());
+ assertEquals("DELETE", report.get("confirmMethod"), () -> report.toString());
+ assertEquals("/x/7", report.get("confirmUrl"), () -> report.toString());
+ }
+
+ @Test void b02_cancelSendsNothing() {
+ assertEquals(1, ((Number)report.get("fetchesAfterCancel")).intValue(), () -> report.toString());
+ }
+
+ @Test void c01_dangerConfirmFocusesCancelFirst() {
+ assertEquals(Boolean.TRUE, report.get("dangerCancelFocused"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("builtInResolved"), () -> report.toString());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleBadges_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleBadges_Test.java
new file mode 100644
index 0000000000..e9743539c0
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleBadges_Test.java
@@ -0,0 +1,121 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.console.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * The count-badge runtime {@code juneau-badges.js}: labels, hide-at-zero, error handling, scope, visibility, drain
+ * refresh, tooltip and click navigation. Lives in this module because it owns the Node harness plumbing and the
+ * DOM shims; runs when node is on PATH.
+ */
+class ConsoleBadges_Test extends TestBase {
+
+ private static Map<?,?> report() {
+ var r = NodeHarness.report("badges.cjs", ConsoleChromeMixin.BADGES_JS_RESOURCE);
+ assumeTrue(r != null, "node (or badges.cjs) not available - JS layer skipped");
+ return r;
+ }
+
+ @Test void a01_labelsAndAria() {
+ var r = report();
+ assertEquals("3 changes pending", r.get("plainLabel"));
+ assertEquals("1 change pending", r.get("singularLabel"));
+ assertEquals("2 changes pending (1 yours)", r.get("mineLabel"));
+ assertEquals("2 open", r.get("customLabel"));
+ assertEquals("status", r.get("role"));
+ assertEquals("polite", r.get("ariaLive"));
+ assertEquals("jc-header-badges", r.get("inHeaderBadges"));
+ }
+
+ @Test void a02_toneDefaultsToWarningAndDangerPaintsAsError() {
+ var r = report();
+ assertEquals("warning", r.get("defaultTone"));
+ assertEquals("error", r.get("dangerTone"));
+ }
+
+ @Test void a03_zeroHidesAndGrowthPulses() {
+ var r = report();
+ assertEquals(true, r.get("zeroHidden"));
+ assertEquals(true, r.get("reshown"));
+ assertEquals(true, r.get("pulsed"));
+ }
+
+ @Test void a04_forbiddenRemovesBadgeAndStopsPolling() {
+ var r = report();
+ assertEquals(true, r.get("removedOn403"));
+ assertEquals(1, ((Number)r.get("e65Count")).intValue());
+ assertEquals(true, r.get("noRescheduleOn403"));
+ assertEquals(1, ((Number)r.get("fetchCountAfter403")).intValue());
+ }
+
+ @Test void a05_badResponseGoesStaleAndBacksOff() {
+ var r = report();
+ assertEquals(true, r.get("stale"));
+ assertEquals(true, r.get("e66"));
+ assertEquals(true, r.get("e65OnNetworkError"));
+ assertEquals(List.of(10000, 20000), ((List<?>)r.get("backoff")).stream().map(o -> ((Number)o).intValue()).toList());
+ }
+
+ @Test void a06_refreshMsBelowFloorIsClamped() {
+ assertEquals(5000, ((Number)report().get("clampedInterval")).intValue());
+ }
+
+ @Test void a07_visibleWhenGatesMountAndFetch() {
+ var r = report();
+ assertEquals(true, r.get("visibleWhenShown"));
+ assertEquals(true, r.get("visibleWhenHiddenNotMounted"));
+ assertEquals(List.of("/yes"), r.get("visibleWhenFetched"));
+ }
+
+ @Test void a08_scopeResolvesFromNavLeaf() {
+ var r = report();
+ assertEquals("/p?a=b%20c&beanType=Suspension,Hold", r.get("scopedUrl"));
+ assertEquals(true, r.get("unscopedPageNoFetch"));
+ assertEquals(true, r.get("unscopedPageReschedules"));
+ }
+
+ @Test void a09_drainRefreshesLinkedCardsOnlyWhenAnItemLeaves() {
+ var r = report();
+ assertEquals(0, ((Number)r.get("reloadedOnGrowth")).intValue());
+ assertEquals(List.of("rules-table"), r.get("reloadedOnDrain"));
+ }
+
+ @Test void a10_tooltipHonorsCapAndCloses() {
+ var r = report();
+ assertEquals(true, r.get("tooltipOpen"));
+ assertEquals("tooltip", r.get("tooltipRole"));
+ assertEquals(3, ((Number)r.get("tooltipItems")).intValue());
+ assertEquals("and 1 more", r.get("tooltipMore"));
+ assertEquals(true, r.get("describedBy"));
+ assertEquals(true, r.get("escapeCloses"));
+ assertEquals(true, r.get("blurCloses"));
+ }
+
+ @Test void a11_clickNavigation() {
+ var r = report();
+ assertEquals("/ui/changes", r.get("plainNav"));
+ assertEquals("/ui/changes?state=filter(beanType%3D%24eq(Suspension))", r.get("filteredNav"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleCards_Shell_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleCards_Shell_Test.java
index cdf8309ced..51a9165d6b 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleCards_Shell_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleCards_Shell_Test.java
@@ -235,8 +235,8 @@ class ConsoleCards_Shell_Test extends TestBase {
@Test void b02_csrfHeaderOnNonGetOnly() {
var calls = list(context("csrf").get("calls"));
- assertEquals(Map.of("url", "/get", "method", "GET", "headers", Map.of()), calls.get(0));
- assertEquals(Map.of("url", "/post", "method", "POST", "headers", Map.of("X-My-Csrf", "tok-123")), calls.get(1));
+ assertEquals(Map.of("url", "/get", "method", "GET", "headers", Map.of("Accept", "application/json")), calls.get(0));
+ assertEquals(Map.of("url", "/post", "method", "POST", "headers", Map.of("Accept", "application/json", "X-My-Csrf", "tok-123")), calls.get(1));
}
@Test void b03_errorMessagePrecedence() {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleVisibility_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleVisibility_Test.java
new file mode 100644
index 0000000000..de0962e429
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleVisibility_Test.java
@@ -0,0 +1,63 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.console.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Card-level {@code visibleWhen} in {@code juneau-console.js}: the shell hides a card whose rules do not match the
+ * page's top-level {@code facts}, proven against the same corpus as the Java and views evaluators. Runs when node is
+ * on PATH.
+ */
+class ConsoleVisibility_Test extends TestBase {
+
+ private static Map<?,?> report() {
+ var r = NodeHarness.report("console-visibility.cjs", ConsoleChromeMixin.CONSOLE_JS_RESOURCE);
+ assumeTrue(r != null, "node (or console-visibility.cjs) not available - JS layer skipped");
+ return r;
+ }
+
+ @Test void a01_everyCorpusCaseAgreesWithJava() {
+ var cases = (List<?>)report().get("cases");
+ assertFalse(cases.isEmpty());
+ for (var o : cases) {
+ var m = (Map<?,?>)o;
+ assertEquals(m.get("expected"), m.get("actual"), String.valueOf(m.get("name")));
+ }
+ }
+
+ @Test void a02_noFactsFailsClosedExceptForCardsWithNoRule() {
+ assertEquals(List.of("plain"), report().get("noFactsMounted"));
+ }
+
+ @Test void a03_singleRuleObjectIsAListOfOne() {
+ assertEquals(List.of("single"), report().get("singleObjectMounted"));
+ }
+
+ @Test void a04_unknownOpHidesTheCardAndLogs() {
+ var r = report();
+ assertEquals(List.of(), r.get("unknownOpMounted"));
+ assertEquals(true, r.get("unknownOpLogged"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DialogWizard_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DialogWizard_BrowserTest.java
new file mode 100644
index 0000000000..2fb0fe41f0
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DialogWizard_BrowserTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Runs the REAL served scripts in headless Chromium and drives a two-step "Review changes" then "Apply" dialog through
+ * the {@code DialogHandle}: the visible button text changes, and exactly one write is sent, at the end.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does. The prober ({@code dialog-wizard.html.cjs}) is resolved by name from the profile's {@code juneau.jsTests.harnessDir}.
+ */
+@EnabledIfSystemProperty(named=DialogWizard_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class DialogWizard_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static Map<?,?> report;
+
+ private static String resource(String path) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harnessDir")).resolve("dialog-wizard.html.cjs");
+
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+ + resource(ViewsMixin.RENDERS_JS_RESOURCE) + "\n</script>\n<script>\n"
+ + resource(ViewsMixin.VIEWS_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("dialog-wizard.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ /** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("dialog-wizard-stdout.json");
+ var stderr = dir.resolve("dialog-wizard-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @Test void a01_runtimeLoadedWithoutScriptErrors() {
+ assertEquals(Boolean.TRUE, report.get("hasOpen"), () -> report.toString());
+ assertEquals(List.of(), report.get("jsFailures"), () -> report.toString());
+ }
+
+ @Test void b01_firstStepIsRelabeledAndNothingIsSentOnNext() {
+ assertEquals("Review changes", report.get("firstLabel"), () -> report.toString());
+ assertEquals("edit", report.get("firstStep"), () -> report.toString());
+ assertEquals("review", report.get("reviewStep"), () -> report.toString());
+ assertEquals("Apply 1 change", report.get("reviewLabel"), () -> report.toString());
+ assertEquals("Review", report.get("reviewTitle"), () -> report.toString());
+ assertEquals(0, ((Number)report.get("fetchesAtReview")).intValue(), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("openAtReview"), () -> report.toString());
+ }
+
+ @Test void b02_finalConfirmSendsExactlyOneWrite() {
+ assertEquals("submitted", report.get("closedResult"), () -> report.toString());
+ assertEquals(1, ((Number)report.get("fetchesAtEnd")).intValue(), () -> report.toString());
+ assertEquals("POST", report.get("method"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("dialogGone"), () -> report.toString());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ExportLoud_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ExportLoud_BrowserTest.java
new file mode 100644
index 0000000000..739370a5fc
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ExportLoud_BrowserTest.java
@@ -0,0 +1,128 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Runs the REAL served scripts in headless Chromium and asserts the ribbon "export loud" contract: with no JSZip
+ * loaded, a hard-listed {@code excel} button renders visibly disabled with a tip naming JSZip, while {@code copy}
+ * stays enabled and fires.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does. The prober ({@code export-loud.html.cjs}) is resolved by name from the profile's {@code juneau.jsTests.harnessDir}.
+ */
+@EnabledIfSystemProperty(named=ExportLoud_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class ExportLoud_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static Map<?,?> report;
+
+ private static String resource(String path) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harnessDir")).resolve("export-loud.html.cjs");
+
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+ + resource(ViewsMixin.RIBBON_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("export-loud.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ /** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("export-loud-stdout.json");
+ var stderr = dir.resolve("export-loud-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @Test void a01_runtimeLoadedWithoutScriptErrors() {
+ assertEquals(Boolean.TRUE, report.get("hasBuild"), () -> report.toString());
+ // The missing library is reported loudly (E-JS-69) on purpose; anything else logged as an error is a real failure.
+ var failures = ((List<?>)report.get("jsFailures")).stream().map(String::valueOf).toList();
+ assertEquals(List.of(), failures.stream().filter(f -> ! f.contains("E-JS-69")).toList(), () -> report.toString());
+ assertEquals(1, failures.stream().filter(f -> f.contains("E-JS-69") && f.contains("JSZip")).count(), () -> report.toString());
+ }
+
+ @Test void b01_missingLibraryRendersADisabledVisibleButtonWithATip() {
+ var buttons = (List<?>)report.get("buttons");
+ assertEquals(2, buttons.size(), () -> report.toString());
+ // Declared order is kept: copy first, then the disabled excel.
+ assertEquals(Boolean.FALSE, ((Map<?,?>)buttons.get(0)).get("disabled"), () -> report.toString());
+ assertEquals(Boolean.TRUE, ((Map<?,?>)buttons.get(1)).get("disabled"), () -> report.toString());
+ var excel = buttons.stream().map(b -> (Map<?,?>)b).filter(b -> String.valueOf(b.get("tip")).contains("JSZip")).findFirst().orElseThrow();
+ var copy = buttons.stream().map(b -> (Map<?,?>)b).filter(b -> b != excel).findFirst().orElseThrow();
+ assertEquals(Boolean.FALSE, copy.get("disabled"), () -> report.toString());
+ assertEquals(Boolean.TRUE, excel.get("disabled"), () -> report.toString());
+ assertEquals(Boolean.TRUE, excel.get("visible"), () -> report.toString());
+ assertTrue(((String)excel.get("tip")).contains("JSZip"), () -> report.toString());
+ }
+
+ @Test void b02_onlyTheEnabledButtonFires() {
+ assertEquals(List.of("copy"), report.get("triggered"), () -> report.toString());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/FileRunViewSource_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/FileRunViewSource_Test.java
index 6db4c8cfa4..85b4c25ba2 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/FileRunViewSource_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/FileRunViewSource_Test.java
@@ -24,6 +24,7 @@ import java.util.*;
import java.util.concurrent.atomic.*;
import org.apache.juneau.*;
+import org.apache.juneau.rest.server.runreport.*;
import org.apache.juneau.rest.server.views.RunViewSource.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.*;
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/NodeHarness.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/NodeHarness.java
new file mode 100644
index 0000000000..f0cc81a8c2
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/NodeHarness.java
@@ -0,0 +1,133 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.marshall.marshaller.*;
+
+/**
+ * Generic Node plumbing for the plain-Node behavioral harnesses under {@code src/test/js}.
+ *
+ * <p>
+ * Each harness is run as {@code node <harness> <asset-file>...}, where every asset is a classpath resource copied
+ * to a temp file, and must print exactly one JSON object to stdout. Reports are cached per harness-and-assets.
+ * A machine without Node (or without the harness on any known path) yields {@code null}, which callers turn into a
+ * skip.
+ */
+final class NodeHarness {
+
+ private static final Map<String,Map<?,?>> CACHE = new ConcurrentHashMap<>();
+ private static final Set<String> UNAVAILABLE = ConcurrentHashMap.newKeySet();
+
+ private NodeHarness() {}
+
+ /**
+ * Runs {@code harnessName} against the given classpath resources (resolved relative to {@link ViewsMixin}) and
+ * returns its parsed report, or {@code null} when it cannot run on this machine.
+ */
+ static Map<?,?> report(String harnessName, String...resources) {
+ var key = harnessName + Arrays.toString(resources);
+ if (UNAVAILABLE.contains(key))
+ return null;
+ var cached = CACHE.get(key);
+ if (cached != null)
+ return cached;
+ try {
+ var harness = nodeAvailable() ? locate(harnessName) : null;
+ if (harness == null) {
+ UNAVAILABLE.add(key);
+ return null;
+ }
+ var report = Json.to(run(harness, harnessName, resources), Map.class);
+ CACHE.put(key, report);
+ return report;
+ } catch (Exception e) {
+ throw new AssertionError("could not run " + harnessName + ": " + e, e);
+ }
+ }
+
+ private static String run(Path harness, String harnessName, String[] resources) throws Exception {
+ var temps = new ArrayList<Path>();
+ var stdout = Files.createTempFile("node-harness-stdout-", ".json");
+ var stderr = Files.createTempFile("node-harness-stderr-", ".txt");
+ try {
+ var args = new ArrayList<String>(List.of("node", harness.toString()));
+ for (var r : resources) {
+ var f = Files.createTempFile("node-harness-asset-", ".js");
+ temps.add(f);
+ try (var in = ViewsMixin.class.getResourceAsStream(r)) {
+ assertNotNull(in, () -> "missing classpath resource: " + r);
+ Files.writeString(f, new String(in.readAllBytes(), UTF_8), UTF_8);
+ }
+ args.add(f.toString());
+ }
+ var p = new ProcessBuilder(args).redirectOutput(stdout.toFile()).redirectError(stderr.toFile()).start();
+ if (!p.waitFor(60, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ fail(harnessName + " did not finish within 60s; stderr:\n" + quietRead(stderr));
+ }
+ if (p.exitValue() != 0)
+ fail(harnessName + " exited " + p.exitValue() + "; stderr:\n" + quietRead(stderr) + "\nstdout:\n" + quietRead(stdout));
+ return Files.readString(stdout, UTF_8);
+ } finally {
+ for (var f : temps)
+ Files.deleteIfExists(f);
+ Files.deleteIfExists(stdout);
+ Files.deleteIfExists(stderr);
+ }
+ }
+
+ private static boolean nodeAvailable() {
+ try {
+ var p = new ProcessBuilder("node", "--version").redirectErrorStream(true).start();
+ if (!p.waitFor(5, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ return false;
+ }
+ return p.exitValue() == 0;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private static Path locate(String harnessName) {
+ var basedir = System.getProperty("basedir");
+ if (basedir != null) {
+ var p = Path.of(basedir, "src/test/js/" + harnessName);
+ if (Files.isRegularFile(p))
+ return p.toAbsolutePath().normalize();
+ }
+ for (var rel : List.of("src/test/js/" + harnessName, "juneau-rest/juneau-rest-server-views/src/test/js/" + harnessName)) {
+ var p = Path.of(rel);
+ if (Files.isRegularFile(p))
+ return p.toAbsolutePath().normalize();
+ }
+ return null;
+ }
+
+ private static String quietRead(Path p) {
+ try { return Files.readString(p, UTF_8); }
+ catch (IOException e) { return "(unreadable: " + e.getMessage() + ")"; }
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PendingBadge_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PendingBadge_BrowserTest.java
new file mode 100644
index 0000000000..0b7fdf6cab
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PendingBadge_BrowserTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.console.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Runs the REAL served {@code juneau-badges.js} (and {@code chrome.css}) in headless Chromium: a count badge renders
+ * visibly in the header with its label, hovering lists the items, Escape closes the list, clicking navigates, and a
+ * 403 removes the badge. Lives in this module for the same reason as {@code ConsoleBadges_Test}.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does. The prober ({@code badges.html.cjs}) is resolved by name from the profile's {@code juneau.jsTests.harnessDir}.
+ */
+@EnabledIfSystemProperty(named=PendingBadge_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class PendingBadge_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static Map<?,?> report;
+
+ private static String resource(String path) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harnessDir")).resolve("badges.html.cjs");
+
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><style>\n"
+ + resource("/org/apache/juneau/console/chrome.css")
+ + "\n</style></head><body><div class=\"jc-header-actions\"></div>\n<script>\n"
+ + resource(ConsoleChromeMixin.BADGES_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("pending-badge.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ /** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("pending-badge-stdout.json");
+ var stderr = dir.resolve("pending-badge-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @Test void a01_scriptLoadsWithoutErrors() {
+ assertEquals(Boolean.TRUE, report.get("hasMount"), () -> report.toString());
+ assertEquals(List.of(), report.get("jsFailures"), () -> report.toString());
+ }
+
+ @Test void b01_badgeRendersVisiblyInTheHeader() {
+ assertEquals("3 changes pending (1 yours)", report.get("label"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("visible"), () -> report.toString());
+ assertEquals("jc-header-badges", report.get("hostClass"), () -> report.toString());
+ }
+
+ @Test void b02_hoverListsItemsAndEscapeCloses() {
+ assertEquals(2, ((Number)report.get("popoverItems")).intValue(), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("popoverClosedOnEscape"), () -> report.toString());
+ }
+
+ @Test void b03_clickNavigates() {
+ assertEquals("#changes", report.get("hash"), () -> report.toString());
+ }
+
+ @Test void b04_forbiddenRemovesTheBadge() {
+ assertEquals(Boolean.TRUE, report.get("deniedRemoved"), () -> report.toString());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RelatedList_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RelatedList_BrowserTest.java
new file mode 100644
index 0000000000..569b4e1318
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RelatedList_BrowserTest.java
@@ -0,0 +1,324 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+import static org.apache.juneau.test.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.beanquery.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.datatables.*;
+import org.apache.juneau.rest.server.datatables.adapter.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+import com.sun.net.httpserver.*;
+
+/**
+ * A nested related list (a {@code ViewTable} in a row's detail panel) in a real browser against a real JDK
+ * {@link HttpServer}: it pages, sorts and runs a row action, each as a request the server actually received.
+ *
+ * <p>
+ * Row 1 owns 30 events, so the nested table has a second page. The prober pages forward, sorts the {@code what}
+ * column descending, and runs the {@code ack} row action from the nested row's menu; the server records each
+ * request and the assertions read those records, not just the DOM.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does: {@code mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test}.
+ */
+@EnabledIfSystemProperty(named=RelatedList_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class RelatedList_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ /** A root-table row; a public static class with getters so the bean-query context infers its columns. */
+ public static class Row {
+ private final int id;
+ private final String name;
+
+ Row(int id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public int getId() { return id; }
+ public String getName() { return name; }
+ }
+
+ /** A nested-table row, scoped to its parent root row by {@code parentId}. */
+ public static class Event {
+ private final int parentId;
+ private final String what;
+
+ Event(int parentId, String what) {
+ this.parentId = parentId;
+ this.what = what;
+ }
+
+ public int getParentId() { return parentId; }
+ public String getWhat() { return what; }
+ }
+
+ private static List<Row> rootRows() {
+ return List.of(new Row(1, "Row 01"), new Row(2, "Row 02"));
+ }
+
+ private static List<Event> allEvents() {
+ var l = new ArrayList<Event>();
+ for (var i = 1; i <= 30; i++)
+ l.add(new Event(1, String.format("evt-%02d", i)));
+ l.add(new Event(2, "detail for 2"));
+ return l;
+ }
+
+ /** Every {@code /nested} request's raw query string, in arrival order; proves the scope param is re-derived per expand. */
+ private final List<String> nestedQueries = new CopyOnWriteArrayList<>();
+
+ /** Every {@code /nested} request body (the DataTables paging/ordering envelope), in arrival order. */
+ private final List<String> nestedBodies = new CopyOnWriteArrayList<>();
+
+ /** Every {@code /ack} request: method, CSRF header and body. */
+ private final List<String> acks = new CopyOnWriteArrayList<>();
+
+ private HttpServer server;
+ private int port;
+
+ @BeforeEach
+ void startServer() throws IOException {
+ server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+ port = server.getAddress().getPort();
+ var rootContext = InMemoryBeanQueryContext.create(Row.class).columns("id", "name").build();
+ var eventContext = InMemoryBeanQueryContext.create(Event.class).columns("parentId", "what").build();
+
+ server.createContext("/query", ex -> {
+ try {
+ if (neq(ex.getRequestMethod(), "POST")) {
+ ex.sendResponseHeaders(405, -1);
+ return;
+ }
+ String body;
+ try (var in = ex.getRequestBody()) {
+ body = new String(in.readAllBytes(), UTF_8);
+ }
+ String json;
+ try (var s = rootContext.getSession(rootRows())) {
+ json = Json.of(DataTablesQuery.run(Json.to(body, DataTablesRequest.class), s));
+ }
+ writeJson(ex, json);
+ } catch (Exception e) {
+ sendFailure(ex, e);
+ }
+ });
+ // A distinct "/query/" context (longest-prefix match) serves the row-detail GET envelope at /query/{id};
+ // an empty-fields 200 is all expandDetailRow (juneau-views.js) requires to proceed to the nested table.
+ server.createContext("/query/", ex -> writeJson(ex, "{\"contractVersion\":\"1\",\"fields\":{}}"));
+ server.createContext("/nested", ex -> {
+ try {
+ if (neq(ex.getRequestMethod(), "POST")) {
+ ex.sendResponseHeaders(405, -1);
+ return;
+ }
+ var query = ex.getRequestURI().getRawQuery();
+ nestedQueries.add(String.valueOf(query));
+ var parentId = queryParam(query, "parentId");
+ var scoped = allEvents().stream().filter(e -> String.valueOf(e.getParentId()).equals(parentId)).toList();
+ String body;
+ try (var in = ex.getRequestBody()) {
+ body = new String(in.readAllBytes(), UTF_8);
+ }
+ nestedBodies.add(body);
+ String json;
+ try (var s = eventContext.getSession(scoped)) {
+ json = Json.of(DataTablesQuery.run(Json.to(body, DataTablesRequest.class), s));
+ }
+ writeJson(ex, json);
+ } catch (Exception e) {
+ sendFailure(ex, e);
+ }
+ });
+ server.createContext("/ack/", ex -> {
+ try (var in = ex.getRequestBody()) {
+ acks.add(ex.getRequestMethod() + " " + ex.getRequestURI().getPath() + " csrf="
+ + ex.getRequestHeaders().getFirst("X-CSRF-Token") + " body=" + new String(in.readAllBytes(), UTF_8));
+ }
+ writeJson(ex, "{\"outcome\":\"success\",\"message\":\"Acknowledged\"}");
+ });
+ server.createContext("/", ex -> {
+ var bytes = fixturePage().getBytes(UTF_8);
+ ex.getResponseHeaders().add("Content-Type", "text/html;charset=utf-8");
+ ex.sendResponseHeaders(200, bytes.length);
+ try (var os = ex.getResponseBody()) {
+ os.write(bytes);
+ }
+ });
+ server.start();
+ }
+
+ @AfterEach
+ void stopServer() {
+ server.stop(0);
+ }
+
+ private static String queryParam(String query, String name) {
+ if (query == null)
+ return null;
+ for (var pair : query.split("&")) {
+ var i = pair.indexOf('=');
+ if (i >= 0 && URLDecoder.decode(pair.substring(0, i), UTF_8).equals(name))
+ return URLDecoder.decode(pair.substring(i + 1), UTF_8);
+ }
+ return null;
+ }
+
+ /**
+ * Answers a handler failure with a 500 carrying the stack trace, instead of throwing (which makes the JDK server
+ * drop the connection with no response) - so the prober's network diagnostics show why a draw failed.
+ */
+ private static void sendFailure(HttpExchange ex, Exception e) throws IOException {
+ var sw = new StringWriter();
+ e.printStackTrace(new PrintWriter(sw));
+ System.err.println(sw);
+ var bytes = sw.toString().getBytes(UTF_8);
+ ex.getResponseHeaders().add("Content-Type", "text/plain;charset=utf-8");
+ ex.sendResponseHeaders(500, bytes.length);
+ try (var os = ex.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+
+ private static void writeJson(HttpExchange ex, String json) throws IOException {
+ var bytes = json.getBytes(UTF_8);
+ ex.getResponseHeaders().add("Content-Type", "application/json");
+ ex.sendResponseHeaders(200, bytes.length);
+ try (var os = ex.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+
+ private static String resource(Class<?> anchor, String path) throws IOException {
+ try (var in = anchor.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path + " (relative to " + anchor.getName() + ")");
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ private static String fixturePage() throws IOException {
+ var nodeModules = Path.of(requiredProperty("juneau.jsTests.dir")).resolve("node_modules");
+ var jquery = Files.readString(nodeModules.resolve("jquery/dist/jquery.min.js"));
+ var dataTablesJs = Files.readString(nodeModules.resolve("datatables.net/js/dataTables.min.js"));
+ var datatablesGlue = resource(DataTablesMixin.class, "juneau-datatables.js");
+ var viewsJs = resource(ViewsMixin.class, ViewsMixin.VIEWS_JS_RESOURCE);
+
+ var rootMeta = "{\"contractVersion\":\"5\",\"id\":\"root\",\"dataMode\":\"server\",\"dataUrl\":\"/query\","
+ + "\"columns\":[{\"data\":\"id\",\"title\":\"ID\"},{\"data\":\"name\",\"title\":\"Name\"}]}";
+ var nestedMeta = "{\"contractVersion\":\"5\",\"id\":\"events\",\"dataMode\":\"server\",\"dataUrl\":\"/nested\","
+ + "\"columns\":[{\"data\":\"what\",\"title\":\"What\"}],"
+ + "\"rowActions\":[{\"id\":\"ack\",\"label\":\"Acknowledge\",\"endpoint\":\"/ack/{what}\",\"method\":\"POST\","
+ + "\"onSuccess\":\"redraw\"}]}";
+
+ // The nested sidecar carries type="application/json" so the browser never executes it as script once the
+ // row-detail template is cloned into the live document.
+ return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n"
+ + "<table data-juneau-view=\"root\" id=\"root\" data-juneau-csrf=\"tok-9\"></table>\n"
+ + "<script type=\"application/json\" id=\"juneau-view:root\">" + rootMeta + "</script>\n"
+ + "<template data-juneau-row-detail=\"1\" data-juneau-detail-url=\"/query/{id}\">\n"
+ + " <div class=\"juneau-view-detail-nested\" data-juneau-nested=\"1\" data-juneau-nested-contract=\"2\""
+ + " data-juneau-nested-scope-param=\"parentId\">\n"
+ + " <table data-juneau-view=\"events\" data-juneau-csrf=\"tok-9\"></table>\n"
+ + " <script type=\"application/json\" data-juneau-nested-meta=\"events\">" + nestedMeta + "</script>\n"
+ + " </div>\n"
+ + "</template>\n"
+ + "<script>\n" + jquery + "\n</script>\n"
+ + "<script>\n" + dataTablesJs + "\n</script>\n"
+ + "<script>\n" + datatablesGlue + "\n</script>\n"
+ + "<script>\n" + resource(ViewsMixin.class, ViewsMixin.RENDERS_JS_RESOURCE) + "\n</script>\n"
+ + "<script>\n" + viewsJs + "\n</script>\n"
+ + "</body></html>";
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ private Map<?,?> runHarness() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("related-list.cjs");
+ var baseUrl = "http://127.0.0.1:" + port + "/";
+
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), baseUrl);
+ var stdout = dir.resolve("related-list-stdout.json");
+ var stderr = dir.resolve("related-list-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (! p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Json.to(Files.readString(stdout), Map.class);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @Test void a01_nestedListPagesToTheSecondPage() throws Exception {
+ var report = (Map<?,?>)runHarness().get("paging");
+ assertEquals("evt-01", report.get("firstPageFirstRow"), () -> report.toString());
+ assertNotEquals(report.get("firstPageFirstRow"), report.get("secondPageFirstRow"), () -> report.toString());
+ assertTrue(nestedBodies.stream().anyMatch(b -> ! b.contains("\"start\":0")), () -> "no paged request in " + nestedBodies);
+ }
+
+ @Test void a02_nestedListSortsOnTheServer() throws Exception {
+ var report = (Map<?,?>)runHarness().get("sorting");
+ assertEquals("evt-30", report.get("firstRowDescending"), () -> report.toString());
+ assertTrue(nestedBodies.stream().anyMatch(b -> b.contains("\"desc\"")), () -> "no descending order in " + nestedBodies);
+ }
+
+ @Test void a03_nestedRowActionRunsAgainstTheServer() throws Exception {
+ var report = (Map<?,?>)runHarness().get("action");
+ assertEquals(Boolean.TRUE, report.get("menuOpened"), () -> report.toString());
+ assertEquals(1, acks.size(), () -> acks.toString());
+ assertTrue(acks.get(0).startsWith("POST /ack/evt-"), acks.get(0));
+ assertTrue(acks.get(0).contains("csrf=tok-9"), acks.get(0));
+ }
+
+ @Test void a04_noScriptErrors() throws Exception {
+ assertEquals(List.of(), runHarness().get("jsFailures"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowAction_Tone_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowAction_Tone_Test.java
new file mode 100644
index 0000000000..d7f4035a0c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowAction_Tone_Test.java
@@ -0,0 +1,67 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/** Unit tests for {@link RowAction}'s {@code tone}, {@code confirmLabel} and {@code confirmRenderer} fields. */
+class RowAction_Tone_Test extends TestBase {
+
+ @Test void a01_unsetFieldsAreNull() {
+ var a = RowAction.create("x");
+ assertNull(a.tone);
+ assertNull(a.confirmLabel);
+ assertNull(a.confirmRenderer);
+ }
+
+ @Test void a02_dangerWireToken() {
+ assertEquals("danger", RowAction.Tone.DANGER.wire());
+ assertEquals("danger", RowAction.create("x").tone(RowAction.Tone.DANGER).tone);
+ }
+
+ @Test void a03_defaultWireToken() {
+ assertEquals("default", RowAction.Tone.DEFAULT.wire());
+ assertEquals("default", RowAction.create("x").tone(RowAction.Tone.DEFAULT).tone);
+ }
+
+ @Test void a04_confirmLabelSetVerbatim() {
+ assertEquals("Delete forever", RowAction.create("x").confirmLabel("Delete forever").confirmLabel);
+ }
+
+ @Test void a05_confirmRendererSetVerbatim() {
+ assertEquals("styled", RowAction.create("x").confirmRenderer("styled").confirmRenderer);
+ }
+
+ @Test void a06_fluentChainReturnsSameInstance() {
+ var a = RowAction.create("x");
+ assertSame(a, a.tone(RowAction.Tone.DANGER).confirmLabel("Go").confirmRenderer("r"));
+ }
+
+ @Test void a07_serializesOnlyWhenSet() {
+ var plain = Json5.of(RowAction.create("x"));
+ assertFalse(plain.contains("tone"), plain);
+ assertFalse(plain.contains("confirmLabel"), plain);
+ var json = Json5.of(RowAction.create("x").tone(RowAction.Tone.DANGER).confirmLabel("Go").confirmRenderer("r"));
+ assertTrue(json.contains("tone:'danger'"), json);
+ assertTrue(json.contains("confirmLabel:'Go'"), json);
+ assertTrue(json.contains("confirmRenderer:'r'"), json);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewChecks_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewChecks_Test.java
index 2cac977789..22de853621 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewChecks_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewChecks_Test.java
@@ -24,6 +24,7 @@ import java.util.*;
import org.apache.juneau.*;
import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.runreport.*;
import org.junit.jupiter.api.*;
class RunViewChecks_Test extends TestBase {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewLog_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewLog_Test.java
index 52fcde85a9..19a343b7ce 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewLog_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewLog_Test.java
@@ -19,13 +19,16 @@ package org.apache.juneau.rest.server.views;
import static org.apache.juneau.BasicTestUtils.*;
import static org.junit.jupiter.api.Assertions.*;
+import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;
import org.apache.juneau.*;
-import org.apache.juneau.rest.server.views.RunEvent.*;
+import org.apache.juneau.rest.server.runreport.*;
+import org.apache.juneau.rest.server.runreport.RunEvent.*;
import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.*;
class RunViewLog_Test extends TestBase {
@@ -153,4 +156,15 @@ class RunViewLog_Test extends TestBase {
pool.shutdown();
assertEquals(LongStream.rangeClosed(1, 4000).boxed().toList(), seqs(log.page(null, 10_000)));
}
+
+ @Test void z01_reportResultFeedsTheLog(@TempDir Path d) throws Exception {
+ Files.writeString(d.resolve("TEST-x.xml"), "<testsuite name=\"com.example.X\"><testcase name=\"ok\" classname=\"com.example.X\"/>"
+ + "<testcase name=\"bad\" classname=\"com.example.X\"><failure message=\"no\">trace</failure></testcase></testsuite>");
+ var log = RunViewLog.create();
+ ReportReaders.forKind("surefire").read(d).toEvents("tests").forEach(log::append);
+ var page = log.page(null, 100);
+ assertEquals("replace", page.events().get(0).toContractMap().get("ev"));
+ assertEquals(3, page.events().size());
+ assertDoesNotThrow(() -> page.validate());
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewMixin_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewMixin_Test.java
index b878dca819..80516d4144 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewMixin_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewMixin_Test.java
@@ -26,8 +26,9 @@ import org.apache.juneau.*;
import org.apache.juneau.marshall.marshaller.*;
import org.apache.juneau.rest.mock.classic.*;
import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.runreport.*;
import org.apache.juneau.rest.server.servlet.*;
-import org.apache.juneau.rest.server.views.RunEvent.*;
+import org.apache.juneau.rest.server.runreport.RunEvent.*;
import org.junit.jupiter.api.*;
class RunViewMixin_Test extends TestBase {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewPage_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewPage_Test.java
index 13c848581e..86b98540c2 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewPage_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewPage_Test.java
@@ -22,7 +22,8 @@ import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import org.apache.juneau.*;
-import org.apache.juneau.rest.server.views.RunEvent.*;
+import org.apache.juneau.rest.server.runreport.*;
+import org.apache.juneau.rest.server.runreport.RunEvent.*;
import org.junit.jupiter.api.*;
class RunViewPage_Test extends TestBase {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunView_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunView_BrowserTest.java
index 3546dc0908..277edac1ce 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunView_BrowserTest.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunView_BrowserTest.java
@@ -30,8 +30,9 @@ import org.apache.juneau.*;
import org.apache.juneau.marshall.marshaller.*;
import org.apache.juneau.rest.mock.classic.*;
import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.runreport.*;
import org.apache.juneau.rest.server.servlet.*;
-import org.apache.juneau.rest.server.views.RunEvent.*;
+import org.apache.juneau.rest.server.runreport.RunEvent.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.condition.*;
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/UrlStateDirectives_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/UrlStateDirectives_BrowserTest.java
new file mode 100644
index 0000000000..b1b82453a8
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/UrlStateDirectives_BrowserTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Runs the REAL served scripts in headless Chromium and asserts the {@code ?state=} directives against the real address
+ * bar and history: a deep link seeds the request params, {@code setDirective} rewrites the bar and the params, and
+ * browser Back restores the prior ids.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does. The prober ({@code url-state-directives.html.cjs}) is resolved by name from the profile's {@code juneau.jsTests.harnessDir}.
+ */
+@EnabledIfSystemProperty(named=UrlStateDirectives_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class UrlStateDirectives_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static Map<?,?> report;
+
+ private static String resource(String path) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harnessDir")).resolve("url-state-directives.html.cjs");
+
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+ + resource(ViewsMixin.RENDERS_JS_RESOURCE) + "\n</script>\n<script>\n"
+ + resource(ViewsMixin.VIEWS_JS_RESOURCE) + "\n</script>\n<script>\n" + resource(ViewsMixin.URLSTATE_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("url-state-directives.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ /** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("url-state-directives-stdout.json");
+ var stderr = dir.resolve("url-state-directives-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @Test void a01_runtimeLoadedWithoutScriptErrors() {
+ assertEquals(Boolean.TRUE, report.get("hasSet"), () -> report.toString());
+ assertEquals(List.of(), report.get("jsFailures"), () -> report.toString());
+ }
+
+ @Test void b01_deepLinkSeedsTheRequestParams() {
+ assertEquals("a,b", ((Map<?,?>)report.get("seededParams")).get("ids"), () -> report.toString());
+ }
+
+ @Test void b02_setDirectiveRewritesBarAndParamsAndRefetches() {
+ var address = (String)report.get("address");
+ assertTrue(address.contains("window(start=2026-10-01T00:00:00Z;end=2026-10-02T00:00:00Z)"), address);
+ var params = (Map<?,?>)report.get("paramsAfterSet");
+ assertEquals("2026-10-01T00:00:00Z", params.get("from"), () -> report.toString());
+ assertEquals("2026-10-02T00:00:00Z", params.get("to"), () -> report.toString());
+ assertEquals(1, ((Number)report.get("reloadsAfterSet")).intValue(), () -> report.toString());
+ }
+
+ @Test void b03_backRestoresThePriorIds() {
+ assertEquals("x,y", report.get("afterPushIds"), () -> report.toString());
+ assertEquals("a,b", report.get("idsAfterBack"), () -> report.toString());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Confirm_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Confirm_Test.java
new file mode 100644
index 0000000000..89089e6231
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Confirm_Test.java
@@ -0,0 +1,107 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/** Source-shape and Node behavioral checks for {@code JuneauViews.dialogs.confirm} and its renderer registry. */
+@SuppressWarnings({
+ "resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_Confirm_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ private static String viewsJs() throws Exception {
+ return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ }
+
+ private static Map<?,?> report() {
+ var r = NodeHarness.report("confirm.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE);
+ assumeTrue(r != null, "node (or confirm.cjs) not available - behavioral layer skipped");
+ return r;
+ }
+
+ @Test void a01_sourceExposesConfirmSurface() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("confirm: confirmPipeline"), "dialogs.confirm");
+ assertTrue(body.contains("registerConfirmRenderer: registerConfirmRenderer"), "rowActions.registerConfirmRenderer");
+ assertTrue(body.contains("registerChrome: registerDialogChrome"), "dialogs.registerChrome");
+ assertTrue(body.contains("E-JS-71"), "unregistered renderer code");
+ }
+
+ @Test void b01_registeredRendererDecidesTheAnswer() {
+ var r = report();
+ assertEquals(true, r.get("surface"));
+ assertEquals(true, r.get("stubOk"));
+ assertEquals(false, r.get("stubCancel"));
+ assertEquals(true, r.get("duplicateKeptFirst"));
+ assertEquals(true, r.get("actionObjectRenderer"));
+ }
+
+ @Test void b02_rendererReceivesTheDescribedRequest() {
+ var saw = (List<?>)report().get("rendererSaw");
+ var first = (Map<?,?>)saw.get(0);
+ assertEquals("danger", first.get("tone"));
+ assertEquals("Go", first.get("confirmLabel"));
+ assertEquals(2, ((Number)first.get("items")).intValue());
+ assertEquals(true, first.get("sameRequest"));
+ var second = (Map<?,?>)saw.get(1);
+ assertEquals("default", second.get("tone"));
+ assertEquals("Confirm", second.get("confirmLabel"));
+ }
+
+ @Test void b03_unregisteredRendererRejectsWithE_JS_71() {
+ assertEquals("E-JS-71", report().get("unregistered"));
+ }
+
+ @Test void b04_builtInModalCapsListAndStylesDanger() {
+ var b = (Map<?,?>)report().get("builtIn");
+ assertEquals(true, b.get("shown"));
+ assertEquals(21, ((Number)b.get("listItems")).intValue());
+ assertEquals("\u2026and 5 more", b.get("lastItem"));
+ assertEquals("Delete", b.get("confirmText"));
+ assertEquals(true, b.get("dangerClass"));
+ assertEquals(true, b.get("cancelFocused"));
+ assertEquals(1, ((Number)b.get("depthWhileOpen")).intValue());
+ }
+
+ @Test void b05_builtInConfirmResolvesAndRunsActionOnlyOnConfirm() {
+ var b = (Map<?,?>)report().get("builtIn");
+ assertEquals(true, b.get("resolved"));
+ assertEquals(1, ((Number)b.get("actionRuns")).intValue());
+ assertEquals(true, b.get("goneAfter"));
+ assertEquals(false, b.get("cancelResolved"));
+ assertEquals(1, ((Number)b.get("actionRunsAfterCancel")).intValue());
+ assertEquals(true, report().get("defaultTonePlain"));
+ assertEquals(0, ((Number)report().get("depthAfter")).intValue());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogHandle_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogHandle_Test.java
new file mode 100644
index 0000000000..c29c6f56a2
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogHandle_Test.java
@@ -0,0 +1,124 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/** Source-shape and Node behavioral checks for the {@code DialogHandle} returned by {@code dialogs.open}. */
+@SuppressWarnings({
+ "resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_DialogHandle_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ private static String viewsJs() throws Exception {
+ return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ }
+
+ private static Map<?,?> report() {
+ var r = NodeHarness.report("dialog-handle.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE);
+ assumeTrue(r != null, "node (or dialog-handle.cjs) not available - behavioral layer skipped");
+ return r;
+ }
+
+ @Test void a01_sourceExposesHandleAndChromeRegistry() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("ui.setConfirmLabel"), "setConfirmLabel");
+ assertTrue(body.contains("ui.onConfirm"), "onConfirm");
+ assertTrue(body.contains("E-JS-72"), "unregistered chrome code");
+ assertTrue(body.contains("depth: dialogLayerCount"), "dialogs.depth");
+ }
+
+ @Test void b01_handleSurface() {
+ assertEquals(true, report().get("handleSurface"));
+ }
+
+ @Test void b02_stepsAdvanceRetreatAndSubmitOnTheLast() {
+ var w = (Map<?,?>)report().get("wizard");
+ assertEquals("edit", w.get("step0"));
+ assertEquals("Next", w.get("label0"));
+ assertEquals(true, w.get("backHidden0"));
+ assertEquals("review", w.get("step1"));
+ assertEquals("Apply", w.get("label1"));
+ assertEquals("Review", w.get("title1"));
+ assertEquals(true, w.get("backShown1"));
+ assertEquals(1, ((Number)w.get("openAfterNext")).intValue());
+ assertEquals("edit", w.get("stepBack"));
+ assertEquals("edit", w.get("stepAfterUnknown"));
+ assertEquals("review", w.get("stepAfterSet"));
+ assertEquals("submitted", w.get("closed"));
+ assertEquals(0, ((Number)w.get("openAfterSubmit")).intValue());
+ }
+
+ @Test void b03_relabelingAndOnConfirmGating() {
+ var g = (Map<?,?>)report().get("gate");
+ assertEquals("Review changes", g.get("confirmLabel"));
+ assertEquals("Back out", g.get("cancelLabel"));
+ assertEquals(true, g.get("fieldsIsObject"));
+ assertEquals(1, ((Number)g.get("openAfterFalse")).intValue());
+ assertEquals(1, ((Number)g.get("openAfterPromiseFalse")).intValue());
+ assertEquals(1, ((Number)g.get("openAfterTruthyNotTrue")).intValue());
+ assertEquals("submitted", g.get("closed"));
+ assertEquals(4, ((Number)g.get("calls")).intValue());
+ assertEquals(0, ((Number)g.get("openAfterTrue")).intValue());
+ }
+
+ @Test void b04_closePopsOnlyItsOwnLayerAndDepthCapHolds() {
+ var c2 = (Map<?,?>)report().get("layers");
+ assertEquals(2, ((Number)c2.get("depthTwo")).intValue());
+ assertEquals(true, c2.get("thirdRefused"));
+ assertEquals(1, ((Number)c2.get("depthAfterInnerClose")).intValue());
+ assertEquals(1, ((Number)c2.get("outerStillOpen")).intValue());
+ assertEquals("cancelled", c2.get("innerClosed"));
+ assertEquals("cancelled", c2.get("outerClosed"));
+ assertEquals(1, ((Number)c2.get("onCancelRan")).intValue());
+ assertEquals(0, ((Number)c2.get("depthEnd")).intValue());
+ }
+
+ @Test void b05_chromeRegistry() {
+ var ch = (Map<?,?>)report().get("chrome");
+ assertEquals(true, ch.get("unknownReturnsNull"));
+ assertEquals(true, ch.get("depthUnchangedByUnknown"));
+ assertEquals(true, ch.get("opened"));
+ var seen = (List<?>)ch.get("seen");
+ assertEquals(1, seen.size());
+ assertEquals(List.of("actions", "cancelBtn", "confirmBtn", "dialog", "dismissBtn", "header"), ((Map<?,?>)seen.get(0)).get("keys"));
+ }
+
+ @Test void b06_openWithActionSubmitsOnlyOnTheLastStep() {
+ var sub = (Map<?,?>)report().get("submit");
+ assertEquals(0, ((Number)sub.get("afterNext")).intValue());
+ assertEquals(1, ((Number)sub.get("total")).intValue());
+ assertEquals("/x/apply", sub.get("url"));
+ assertEquals("apply", sub.get("action"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ExportDetect_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ExportDetect_Test.java
new file mode 100644
index 0000000000..77e3b2ad74
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ExportDetect_Test.java
@@ -0,0 +1,83 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/** Source-shape and Node behavioral checks for the ribbon "export loud" contract (disabled button, one logged E-JS-69). */
+@SuppressWarnings({
+ "resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_ExportDetect_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ private static Map<?,?> report() {
+ var r = NodeHarness.report("export-detect.cjs", ViewsMixin.RIBBON_JS_RESOURCE);
+ assumeTrue(r != null, "node (or export-detect.cjs) not available - behavioral layer skipped");
+ return r;
+ }
+
+ @Test void a01_sourceDeclaresTheDependencyTableAndTheCode() throws Exception {
+ var body = c.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("EXPORT_BUTTON_DEPS"), "dependency table");
+ assertTrue(body.contains("E-JS-69"), "code");
+ assertTrue(body.contains("return { ok: ok, missing: missing }"), "result shape");
+ }
+
+ @Test void b01_okAndMissingAreSplitByDependency() {
+ var r = report();
+ assertEquals(Map.of("ok", List.of(), "missing", List.of()), r.get("noButtonsExtension"));
+ assertEquals(Map.of("ok", List.of("copy", "csv"), "missing", List.of()), r.get("plainButtons"));
+ assertEquals(Map.of("ok", List.of("copy"), "missing", List.of(Map.of("id", "excel", "needs", "jszip"))), r.get("hardExcelNoJszip"));
+ assertEquals(Map.of("ok", List.of("pdf"), "missing", List.of()), r.get("hardPdfWithPdfmake"));
+ }
+
+ @Test void b02_optionalEntriesStayASilentOptIn() {
+ var r = report();
+ assertEquals(Map.of("ok", List.of(), "missing", List.of()), r.get("optionalExcelNoJszip"));
+ assertEquals(Map.of("ok", List.of("excel"), "missing", List.of()), r.get("optionalExcelWithJszip"));
+ }
+
+ @Test void c01_missingButtonRendersDisabledWithATipNamingTheLibrary() {
+ var r = report();
+ assertEquals(2, ((Number)r.get("disabledButtonCount")).intValue());
+ assertTrue(((String)r.get("disabledButtonTip")).contains("JSZip"), () -> String.valueOf(r.get("disabledButtonTip")));
+ assertNull(r.get("disabledTitleAttr"));
+ }
+
+ @Test void c02_e_js_69_isLoggedOncePerTable() {
+ var r = report();
+ assertEquals(1, ((Number)r.get("errorCountAcrossBothActions")).intValue());
+ assertEquals(true, r.get("ctxFlagSet"));
+ assertTrue(((String)r.get("errorText")).contains("ribbon export 'excel' needs 'JSZip'"), () -> String.valueOf(r.get("errorText")));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Hooks_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Hooks_Test.java
new file mode 100644
index 0000000000..922e95aa73
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Hooks_Test.java
@@ -0,0 +1,126 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on source-shape coverage for the sanctioned hook namespaces
+ * ({@code JuneauViews.rowActions/dialogs/probeSelection/tables/status/csrf/details/html}), plus a Node behavioral
+ * proof (via {@code hooks-api.cjs}) that every namespace is frozen and exposes the right member shapes.
+ */
+@SuppressWarnings({
+ "resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_Hooks_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ private static String viewsJs() throws Exception {
+ return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // Source-shape (always-on, no node needed)
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_everyNamespaceIsFrozenAndPlacedAfterNsInit() throws Exception {
+ var body = viewsJs();
+ var anchor = body.indexOf("renderAsyncStatus: renderAsyncStatus");
+ assertTrue(anchor >= 0, "NS.init closing anchor not found");
+ for (var ns : List.of("NS.rowActions = Object.freeze(", "NS.dialogs = Object.freeze(",
+ "NS.probeSelection = Object.freeze(", "NS.tables = Object.freeze(", "NS.status = Object.freeze(",
+ "NS.csrf = Object.freeze(", "NS.details = Object.freeze(", "NS.html = Object.freeze(")) {
+ assertTrue(body.indexOf(ns) > anchor, () -> ns + " not found after the NS.init anchor");
+ }
+ }
+
+ @Test void a02_rowActionsRunResolvesContextBeforeDispatching() throws Exception {
+ var body = viewsJs();
+ var start = body.indexOf("NS.rowActions = Object.freeze(");
+ var fragment = body.substring(start, body.indexOf("NS.dialogs = Object.freeze(", start));
+ assertTrue(fragment.contains("hookContextOf"), fragment);
+ assertTrue(fragment.contains("isDialogAction"), fragment);
+ assertTrue(fragment.contains("E-JS-74"), fragment);
+ assertTrue(fragment.contains("E-JS-70"), fragment);
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // Node behavioral harness (hooks-api.cjs) - runs when node is on PATH.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ private static Map<?,?> report() {
+ var r = NodeHarness.report("hooks-api.cjs", ViewsMixin.VIEWS_JS_RESOURCE, ViewsMixin.RENDERS_JS_RESOURCE);
+ assumeTrue(r != null, "node not available or hooks-api.cjs not found - behavioral layer skipped");
+ return r;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String,Object> ns(String name) {
+ return (Map<String,Object>)report().get(name);
+ }
+
+ @Test void b01_everyNamespaceExistsAndIsFrozen() {
+ for (var name : List.of("rowActions", "dialogs", "probeSelection", "tables", "status", "csrf", "details", "html")) {
+ assertEquals(Boolean.TRUE, ns(name).get("exists"), name);
+ assertEquals(Boolean.TRUE, ns(name).get("frozen"), name);
+ }
+ }
+
+ @Test void b02_rowActionsHasTheFullMemberSet() {
+ var members = (Map<?,?>)ns("rowActions").get("members");
+ for (var m : List.of("contextOf", "find", "isDialog", "run", "open", "submit"))
+ assertEquals("function", members.get(m), m);
+ }
+
+ @Test void b03_htmlEscDelegatesToRendersJs() {
+ assertEquals("<a>&"'", report().get("htmlEscOutput"));
+ }
+
+ @Test void b04_csrfHeaders() {
+ var r = report();
+ assertEquals(Map.of("X-Csrf-Token", "tok"), r.get("csrfHeadersWithToken"));
+ assertEquals(Map.of("X-Mine", "tok"), r.get("csrfHeadersCustomName"));
+ assertEquals(Map.of(), r.get("csrfHeadersBlank"));
+ assertEquals(Map.of(), r.get("csrfHeadersNoEl"));
+ assertEquals("tok", r.get("csrfToken"));
+ }
+
+ @Test void b05_tablesHandleAndReload() {
+ var r = report();
+ assertEquals("v", r.get("handleViewId"));
+ assertEquals(Map.of("id", 7), r.get("handleRow"));
+ assertEquals(Boolean.TRUE, r.get("handleNull"));
+ assertEquals(Arrays.asList(null, false), r.get("reloadDefault"));
+ assertEquals(Arrays.asList(null, true), r.get("reloadReset"));
+ assertEquals(Boolean.TRUE, r.get("contextOfNull"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_InlineEdit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_InlineEdit_Test.java
new file mode 100644
index 0000000000..7d43a75ce0
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_InlineEdit_Test.java
@@ -0,0 +1,122 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/** Node behavioral checks for the {@code edit} renderer and the inline cell editor it drives. */
+class ViewsJs_InlineEdit_Test extends TestBase {
+
+ private static Map<?,?> report;
+
+ private static Map<?,?> report() {
+ if (report == null)
+ report = NodeHarness.report("edit-renderer.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE);
+ assumeTrue(report != null, "node (or edit-renderer.cjs) not available - behavioral layer skipped");
+ return report;
+ }
+
+ private static Map<?,?> map(String key) {
+ return (Map<?,?>)report().get(key);
+ }
+
+ @Test void a01_rendererMarkupIsEscapedAndCarriesTheWiring() {
+ assertEquals(Boolean.TRUE, report().get("registered"));
+ for (var e : map("markup").entrySet())
+ assertEquals(Boolean.TRUE, e.getValue(), () -> "markup." + e.getKey());
+ assertEquals(Boolean.TRUE, report().get("noActionPlain"));
+ assertEquals(Boolean.TRUE, report().get("hostileActionEscaped"));
+ assertEquals(Boolean.TRUE, report().get("wrapClass"));
+ }
+
+ @Test void b01_visibleWhenHidesTheMissingActionAndTheFailingRow() {
+ assertEquals(Boolean.TRUE, report().get("unknownActionHides"));
+ assertEquals(Boolean.TRUE, report().get("visibleWhenHides"));
+ }
+
+ @Test void b02_enabledWhenDisablesWithTheReasonAndNeverOpens() {
+ var d = map("enabledWhenDisables");
+ assertEquals(Boolean.TRUE, d.get("disabled"));
+ assertEquals("Closed rows cannot be reassigned", d.get("title"));
+ assertEquals(Boolean.TRUE, report().get("disabledNoEditor"));
+ }
+
+ @Test void c01_enterCommitsTheEditedColumnThroughTheAction() {
+ assertEquals(Boolean.TRUE, report().get("opened"));
+ assertEquals("old", report().get("prefill"));
+ var c = map("commit");
+ assertEquals(1, ((Number)c.get("fetches")).intValue());
+ assertEquals("/x/7", c.get("url"));
+ assertEquals("assign", c.get("action"));
+ assertEquals("7", c.get("targetId"));
+ assertEquals(Map.of("owner", "new"), c.get("fields"));
+ assertEquals(Boolean.TRUE, report().get("closedAfterSuccess"));
+ assertEquals("new", report().get("shownValue"));
+ assertEquals(1, ((Number)report().get("reloadedAfterSuccess")).intValue());
+ }
+
+ @Test void c02_escapeAndUnchangedValueSendNothing() {
+ var e = map("escape");
+ assertEquals(0, ((Number)e.get("fetches")).intValue());
+ assertEquals(Boolean.TRUE, e.get("closed"));
+ assertEquals("old", e.get("restored"));
+ assertEquals(Boolean.TRUE, e.get("pencilBack"));
+ var u = map("unchanged");
+ assertEquals(0, ((Number)u.get("fetches")).intValue());
+ assertEquals(Boolean.TRUE, u.get("closed"));
+ }
+
+ @Test void c03_blurCommits() {
+ var b = map("blurCommit");
+ assertEquals(1, ((Number)b.get("fetches")).intValue());
+ assertEquals(Map.of("owner", "blurred"), b.get("fields"));
+ }
+
+ @Test void d01_failureKeepsTheEditorOpenWithTheErrorInline() {
+ var f = map("failure");
+ assertEquals(Boolean.TRUE, f.get("stillOpen"));
+ assertEquals("Not allowed", f.get("error"));
+ assertEquals(Boolean.TRUE, f.get("errorVisible"));
+ assertEquals("alert", f.get("role"));
+ assertEquals(Boolean.TRUE, f.get("enabled"));
+ assertEquals(0, ((Number)f.get("reloads")).intValue());
+ assertEquals(1, ((Number)report().get("failureBlurNoResubmit")).intValue(), "blur after a failure must not resubmit");
+ }
+
+ @Test void d02_transportRefusalAlsoSurfacesInline() {
+ var r = map("refusal");
+ assertEquals(Boolean.TRUE, r.get("stillOpen"));
+ assertEquals(Boolean.TRUE, r.get("hasMessage"));
+ }
+
+ @Test void e01_selectInputListsTheOptionsAndPrefills() {
+ var s = map("select");
+ assertEquals("SELECT", s.get("tag"));
+ assertEquals(List.of("x", "y", "z"), s.get("options"));
+ assertEquals("y", s.get("value"));
+ }
+
+ @Test void e02_aNestedTablesPencilIsNotTheParentsToHandle() {
+ assertEquals(Boolean.TRUE, report().get("nestedIgnored"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActionsRun_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActionsRun_Test.java
new file mode 100644
index 0000000000..277e7e3525
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActionsRun_Test.java
@@ -0,0 +1,92 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/** Source-shape and Node behavioral checks for {@code rowActions.run} on a confirm-only action with a confirm renderer. */
+@SuppressWarnings({
+ "resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_RowActionsRun_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ private static String viewsJs() throws Exception {
+ return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ }
+
+ private static Map<?,?> report() {
+ var r = NodeHarness.report("row-actions-run.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE);
+ assumeTrue(r != null, "node (or row-actions-run.cjs) not available - behavioral layer skipped");
+ return r;
+ }
+
+ @Test void a01_confirmOnlyPathRoutesThroughThePipeline() throws Exception {
+ var body = viewsJs();
+ var fn = body.substring(body.indexOf("function openConfirmOnlyDialog("));
+ fn = fn.substring(0, fn.indexOf("\n\t}\n"));
+ assertTrue(fn.indexOf("confirmPipeline(") > 0, fn);
+ assertTrue(fn.indexOf("confirmPipeline(") < fn.indexOf("submitActionDialog("), "confirm must precede the submit");
+ }
+
+ @Test void b01_confirmedActionSendsExactlyOneWrite() {
+ var c2 = (Map<?,?>)report().get("confirmed");
+ assertEquals(1, ((Number)c2.get("fetches")).intValue());
+ assertEquals("/x/7", c2.get("url"));
+ assertEquals("DELETE", c2.get("method"));
+ assertEquals("delete", c2.get("action"));
+ assertEquals("7", c2.get("targetId"));
+ }
+
+ @Test void b02_cancelledActionSendsNothing() {
+ assertEquals(1, ((Number)((Map<?,?>)report().get("cancelled")).get("fetches")).intValue());
+ }
+
+ @Test void b03_rendererSeesFilledTitleLabelAndTone() {
+ var asked = (List<?>)report().get("asked");
+ var first = (Map<?,?>)asked.get(0);
+ assertEquals("Delete Widget?", first.get("title"));
+ assertEquals("Delete it", first.get("confirmLabel"));
+ assertEquals("danger", first.get("tone"));
+ }
+
+ @Test void b04_emptyConfirmTokenRefusesBeforeAnythingShows() {
+ var e = (Map<?,?>)report().get("emptyToken");
+ assertEquals(0, ((Number)e.get("asked")).intValue());
+ assertEquals(1, ((Number)e.get("fetches")).intValue());
+ }
+
+ @Test void b05_unknownActionAndOutsideRowReject() {
+ assertEquals("E-JS-70", report().get("unknownAction"));
+ assertEquals("E-JS-74", report().get("outsideRow"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Rules_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Rules_Test.java
new file mode 100644
index 0000000000..e6e1aad772
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Rules_Test.java
@@ -0,0 +1,76 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Cross-language parity proof: {@code rules.cjs} runs the shared {@code visibility-corpus.json} through
+ * {@code JuneauViews.rules.test} (the Java side is {@link VisibilityRule_Corpus_Test}), then checks the
+ * facts-aware row scope. Runs when node is on PATH.
+ */
+class ViewsJs_Rules_Test extends TestBase {
+
+ private static Map<?,?> report() {
+ var r = NodeHarness.report("rules.cjs", ViewsMixin.VIEWS_JS_RESOURCE);
+ assumeTrue(r != null, "node (or rules.cjs) not available - JS/Java parity layer skipped");
+ return r;
+ }
+
+ @Test void a01_everyCorpusCaseAgreesWithJava() {
+ var cases = (List<?>)report().get("cases");
+ assertFalse(cases.isEmpty());
+ for (var o : cases) {
+ var m = (Map<?,?>)o;
+ assertEquals(m.get("expected"), m.get("actual"), String.valueOf(m.get("name")));
+ }
+ }
+
+ @Test void a02_namespaceIsFrozen() {
+ assertEquals(true, report().get("frozen"));
+ }
+
+ @Test void a03_rowScopeMergesRowAndFacts() {
+ var r = report();
+ assertEquals("{\"viewer\":{\"roles\":[\"admin\"]}}", r.get("factsRead"));
+ assertEquals(true, r.get("rowScopeSeesFacts"));
+ assertEquals(true, r.get("rowScopeSeesRow"));
+ assertEquals(false, r.get("rowScopeBoth"));
+ }
+
+ @Test void a04_singleRuleObjectIsAListOfOne() {
+ assertEquals(true, report().get("singleRuleObject"));
+ }
+
+ @Test void a05_noConsolePageMeansEmptyFactsAndFailsClosed() {
+ var r = report();
+ assertEquals("{}", r.get("noContractFacts"));
+ assertEquals(false, r.get("noContractFailsClosed"));
+ }
+
+ @Test void a06_unknownOpHidesAndLogsE73() {
+ var r = report();
+ assertEquals(false, r.get("unknownOp"));
+ assertEquals(true, r.get("unknownOpLogged"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RunView_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RunView_Test.java
index 5ac8e8ca54..b2a1ad6878 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RunView_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RunView_Test.java
@@ -56,7 +56,9 @@ class ViewsJs_RunView_Test extends TestBase {
"tip03_stepExitTooltip", "cap01_domBounds", "cap02_collapsedBuildsNoBlocks",
"inc01_untouchedStepKeepsItsNode", "inc02_rowRebuiltOnlyWhenSuiteChanged", "cmp01_compactMode",
"gold01_fullGolden", "gold02_compactGolden", "gold03_replaceFlow",
- "gold04_idempotentAppend"
+ "gold04_idempotentAppend",
+ "att01_groupingByIdAndTitle", "att02_collapsedByDefaultWithToggle", "att03_newAttemptRegroupsAndKeepsExpansion",
+ "att04_noAttemptsNoToggle", "att05_failureLinkOpensEarlierAttempt", "att06_resetClearsAttemptState"
));
static Map<?,?> report(String harness) {
@@ -133,6 +135,12 @@ class ViewsJs_RunView_Test extends TestBase {
@Test void n02_compactGolden() { assertAllTrue(r(), "gold02_compactGolden"); }
@Test void n03_replaceFlow() { assertAllTrue(r(), "gold03_replaceFlow"); }
@Test void n04_idempotentAppend() { assertAllTrue(r(), "gold04_idempotentAppend"); }
+ @Test void o01_groupingByIdAndTitle() { assertAllTrue(r(), "att01_groupingByIdAndTitle"); }
+ @Test void o02_collapsedByDefaultWithToggle() { assertAllTrue(r(), "att02_collapsedByDefaultWithToggle"); }
+ @Test void o03_newAttemptRegroupsAndKeepsExpansion() { assertAllTrue(r(), "att03_newAttemptRegroupsAndKeepsExpansion"); }
+ @Test void o04_noAttemptsNoToggle() { assertAllTrue(r(), "att04_noAttemptsNoToggle"); }
+ @Test void o05_failureLinkOpensEarlierAttempt() { assertAllTrue(r(), "att05_failureLinkOpensEarlierAttempt"); }
+ @Test void o06_resetClearsAttemptState() { assertAllTrue(r(), "att06_resetClearsAttemptState"); }
@Test void z01_everyCasePassesAndNoneIsMissing() {
var r = r();
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_UrlStateDirectives_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_UrlStateDirectives_Test.java
new file mode 100644
index 0000000000..b0357b8e24
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_UrlStateDirectives_Test.java
@@ -0,0 +1,129 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/** Node behavioral checks for the {@code ?state=} directive registry, the {@code window}/{@code ids} built-ins, and their live wire. */
+class ViewsJs_UrlStateDirectives_Test extends TestBase {
+
+ private static Map<?,?> report() {
+ var r = NodeHarness.report("urlstate-directives.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE, ViewsMixin.URLSTATE_JS_RESOURCE);
+ assumeTrue(r != null, "node (or urlstate-directives.cjs) not available - behavioral layer skipped");
+ return r;
+ }
+
+ @Test void d01_registerDirectiveRejectsReservedAndMalformedNames() {
+ var r = report();
+ assertEquals(true, r.get("registerReserved"));
+ assertEquals(true, r.get("registerBadName"));
+ assertEquals(true, r.get("registerOk"));
+ }
+
+ @Test void d02_registerDirectiveRejectsDuplicateNames() {
+ assertEquals(true, report().get("registerDuplicate"));
+ }
+
+ @Test void d03_registeredDirectiveRoundTripsThroughExt() {
+ var r = report();
+ assertEquals("abc", r.get("extRoundTrip"));
+ assertEquals("probe(abc)", r.get("encoded"));
+ }
+
+ @Test void d04_unregisteredDirectiveStillDropped() {
+ assertNull(report().get("stillDroppedUnregistered"));
+ }
+
+ @Test void d05_isEmptyStateChecksExt() {
+ var r = report();
+ assertEquals(false, r.get("notEmptyBecauseOfExtOnly"));
+ assertEquals(true, r.get("emptyWithNoExt"));
+ assertEquals(true, r.get("emptyWithBlankExt"));
+ }
+
+ @Test void d06_codecThrowOnDecodeIsEJS68AndDirectiveDropped() {
+ var r = report();
+ assertEquals("E-JS-68", r.get("throwingCodecLogged"));
+ assertNull(r.get("throwingCodecValue"));
+ assertEquals("a", r.get("throwingCodecKeepsTab"));
+ }
+
+ @Test void d07_encodeArgPercentEncodesTheReservedChars() {
+ assertEquals("a%3Bb%28c%29d%2Ce%3Df%25", report().get("encodeArgSample"));
+ }
+
+ @Test void d08_windowBuiltin_roundTripsStartAndEnd() {
+ var r = report();
+ assertEquals("2026-10-01T00:00:00Z", r.get("windowStart"));
+ assertEquals("2026-10-02T00:00:00Z", r.get("windowEnd"));
+ }
+
+ @Test void d09_windowBuiltin_toParamsRenamesByConfig() {
+ var r = report();
+ assertEquals("2026-10-01T00:00:00Z", r.get("windowParamFrom"));
+ assertEquals("2026-10-02T00:00:00Z", r.get("windowParamTo"));
+ assertEquals("{\"from\":\"x\"}", r.get("windowOpenEnded"));
+ }
+
+ @Test void d10_idsBuiltin_trimsDedupesAndCaps() {
+ var r = report();
+ assertEquals("a,b", r.get("idsNormalized"));
+ assertEquals(true, r.get("idsCappedAtMax"));
+ assertEquals("Showing the first 2 ids", r.get("idsCapMessage"));
+ assertNull(r.get("idsNoMessageWhenNotCapped"));
+ assertEquals("{\"idList\":\"a,b\"}", r.get("idsParam"));
+ }
+
+ @Test void d11_idsBuiltin_parseThenSerializeIsIdempotent() {
+ var r = report();
+ assertEquals(true, r.get("idsIdempotent"));
+ assertEquals(true, r.get("idsSpecialCharsRoundTrip"));
+ }
+
+ @Test void e01_liveWire_seedsRequestParamsFromTheAddressBar() {
+ var r = report();
+ assertEquals("[\"p\",\"q\"]", r.get("seededFromUrl"));
+ assertEquals("{\"idList\":\"p,q\"}", r.get("requestParamsSeeded"));
+ assertEquals(1, ((Number)r.get("popstateListener")).intValue());
+ }
+
+ @Test void e02_liveWire_setDirectiveUpdatesBarParamsAndRefetches() {
+ var r = report();
+ assertEquals(true, r.get("setDirectiveOk"));
+ assertEquals(1, ((Number)r.get("setDirectiveReloaded")).intValue());
+ var bar = (String)r.get("addressBar");
+ assertTrue(bar.contains("window(start=2026-10-01T00:00:00Z;end=2026-10-02T00:00:00Z)"), bar);
+ assertTrue(bar.contains("ids(p,q)"), bar);
+ var params = (String)r.get("requestParamsAfterSet");
+ assertTrue(params.contains("\"from\":\"2026-10-01T00:00:00Z\"") && params.contains("\"to\":\"2026-10-02T00:00:00Z\""), params);
+ assertEquals("{\"idList\":\"p,q\"}", r.get("requestParamsAfterClear"));
+ assertFalse(((String)r.get("addressBarAfterClear")).contains("window("));
+ assertEquals(true, r.get("setDirectiveUnknown"));
+ }
+
+ @Test void e03_liveWire_popstateReappliesAndRefetches() {
+ var r = report();
+ assertEquals("[\"z\"]", r.get("popstateIds"));
+ assertEquals(1, ((Number)r.get("popstateReloaded")).intValue());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_VisibilityWiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_VisibilityWiring_Test.java
new file mode 100644
index 0000000000..43e272e7ef
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_VisibilityWiring_Test.java
@@ -0,0 +1,92 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Where {@code visibleWhen} takes effect: a Node behavioral proof (via {@code visibility-wiring.cjs}) for row actions,
+ * plus always-on source-shape checks for the ribbon, field grid and region paths.
+ */
+@SuppressWarnings({
+ "resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_VisibilityWiring_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ private static String asset(String path) throws Exception {
+ return c.get(path).run().assertStatus(200).getContent().asString();
+ }
+
+ private static Map<?,?> report() {
+ var r = NodeHarness.report("visibility-wiring.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE);
+ assumeTrue(r != null, "node (or visibility-wiring.cjs) not available - behavioral layer skipped");
+ return r;
+ }
+
+ @Test void a01_menuOmitsActionsHiddenForTheRow() {
+ var r = report();
+ assertEquals(List.of("ack", "page", "view"), r.get("menuOpenRow"));
+ assertEquals(List.of("page", "view"), r.get("menuClosedRow"));
+ }
+
+ @Test void a02_pillAndTriggerHiddenAtDrawTime() {
+ var r = report();
+ assertEquals(true, r.get("pillHidden"));
+ assertEquals(true, r.get("pillShown"));
+ assertEquals(true, r.get("triggerHiddenWhenNothingLeft"));
+ assertEquals(true, r.get("triggerShownWhenSomethingLeft"));
+ }
+
+ @Test void a03_hiddenActionNeverFires() {
+ var r = report();
+ assertEquals(false, r.get("hiddenActionFired"));
+ assertEquals(true, r.get("visibleActionFired"));
+ }
+
+ @Test void b01_ribbonFiltersItemsByFacts() throws Exception {
+ var body = asset(ViewsMixin.RIBBON_JS_PATH);
+ assertTrue(body.contains("ribbonItemVisible"), body.length() + " bytes");
+ assertTrue(body.contains(".filter(ribbonItemVisible)"));
+ }
+
+ @Test void b02_fieldGridSkipsHiddenFields() throws Exception {
+ var body = asset(ViewsMixin.HELPERS_JS_PATH);
+ assertTrue(body.contains("NS.rules.testRow(field.visibleWhen, values)"));
+ }
+
+ @Test void b03_hiddenRegionTakesTheExistingDeferredPath() throws Exception {
+ var body = asset(ViewsMixin.REGIONS_JS_PATH);
+ assertTrue(body.contains("hiddenByVisibleWhen(region)"));
+ assertTrue(body.contains("data-juneau-region-hidden"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Corpus_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Corpus_Test.java
new file mode 100644
index 0000000000..0098611766
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Corpus_Test.java
@@ -0,0 +1,70 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Runs {@code visibility-corpus.json} (shared with {@code rules.cjs}) through {@link VisibilityRule#test}, so the
+ * Java and JS evaluators are proven to agree on every case from one corpus.
+ */
+class VisibilityRule_Corpus_Test extends TestBase {
+
+ static Path locateCorpus() {
+ for (var rel : List.of(
+ "src/test/js/visibility-corpus.json",
+ "juneau-rest/juneau-rest-server-views/src/test/js/visibility-corpus.json")) {
+ var p = Path.of(rel);
+ if (Files.isRegularFile(p))
+ return p;
+ }
+ throw new IllegalStateException("visibility-corpus.json not found");
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test void a01_everyCorpusCaseMatchesItsExpectation() throws IOException {
+ var cases = (List<Map<String,Object>>)Json.to(Files.readString(locateCorpus()), List.class);
+ assertFalse(cases.isEmpty());
+ for (var c : cases) {
+ var rules = ((List<Map<String,Object>>)c.get("rules")).stream().map(VisibilityRule_Corpus_Test::toRule).toList();
+ var facts = (Map<String,Object>)c.get("facts");
+ assertEquals(c.get("expected"), VisibilityRule.test(rules, facts), (String)c.get("name"));
+ }
+ }
+
+ private static VisibilityRule toRule(Map<String,Object> m) {
+ var b = VisibilityRule.when((String)m.get("field"));
+ var op = (String)m.get("op");
+ return switch (op) {
+ case "eq" -> b.eq(m.get("value"));
+ case "ne" -> b.ne(m.get("value"));
+ case "present" -> b.present();
+ case "absent" -> b.absent();
+ case "in" -> b.in(((List<?>)m.get("value")).toArray());
+ case "contains" -> b.contains(m.get("value"));
+ default -> throw new IllegalArgumentException("unknown corpus op '" + op + "'");
+ };
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Test.java
new file mode 100644
index 0000000000..3865ed2194
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Test.java
@@ -0,0 +1,123 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/** Unit tests for the {@link VisibilityRule} builder, its {@code test(rules, map)} evaluator, and its wire shape. */
+class VisibilityRule_Test extends TestBase {
+
+ @Test void a01_eqSerializesFieldOpValue() {
+ var r = VisibilityRule.when("status").eq("open");
+ assertEquals("status", r.field);
+ assertEquals("eq", r.op);
+ assertEquals("open", r.value);
+ }
+
+ @Test void a02_presentAndAbsentOmitValue() {
+ assertNull(VisibilityRule.when("name").present().value);
+ assertNull(VisibilityRule.when("name").absent().value);
+ }
+
+ @Test void a03_inWrapsVarargsAsAList() {
+ assertEquals(List.of("open", "acked"), VisibilityRule.when("status").in("open", "acked").value);
+ }
+
+ @Test void a04_whenRejectsBlankField() {
+ assertThrows(IllegalArgumentException.class, () -> VisibilityRule.when(" "));
+ assertThrows(IllegalArgumentException.class, () -> VisibilityRule.when(null));
+ }
+
+ @Test void a05_eqRejectsNullValue() {
+ assertThrows(IllegalArgumentException.class, () -> VisibilityRule.when("status").eq(null));
+ assertThrows(IllegalArgumentException.class, () -> VisibilityRule.when("status").contains(null));
+ }
+
+ @Test void a06_testMatchesAndRule() {
+ var rules = List.of(VisibilityRule.when("status").eq("open"), VisibilityRule.when("priority").in("p1", "p2"));
+ assertTrue(VisibilityRule.test(rules, Map.of("status", "open", "priority", "p1")));
+ assertFalse(VisibilityRule.test(rules, Map.of("status", "open", "priority", "p3")));
+ }
+
+ @Test void a07_testFailsClosedOnMissingField() {
+ assertFalse(VisibilityRule.test(List.of(VisibilityRule.when("status").ne("open")), Map.of()));
+ }
+
+ @Test void a08_absentMatchesMissingAndNull() {
+ var rules = List.of(VisibilityRule.when("name").absent());
+ assertTrue(VisibilityRule.test(rules, Map.of()));
+ var m = new HashMap<String,Object>();
+ m.put("name", null);
+ assertTrue(VisibilityRule.test(rules, m));
+ assertFalse(VisibilityRule.test(rules, Map.of("name", "a")));
+ }
+
+ @Test void a09_dottedPathResolvesNestedFacts() {
+ var rules = List.of(VisibilityRule.when("facts.viewer.roles").contains("admin"));
+ assertTrue(VisibilityRule.test(rules, Map.of("facts", Map.of("viewer", Map.of("roles", List.of("admin"))))));
+ }
+
+ @Test void a10_emptyRuleListAlwaysMatches() {
+ assertTrue(VisibilityRule.test(List.of(), Map.of()));
+ assertTrue(VisibilityRule.test(null, Map.of()));
+ }
+
+ @Test void a11_unknownOpThrows() {
+ var r = VisibilityRule.when("status").eq("open");
+ r.op = "bogus";
+ var ex = assertThrows(IllegalArgumentException.class, () -> VisibilityRule.test(List.of(r), Map.of("status", "open")));
+ assertTrue(ex.getMessage().contains("'bogus'"), ex.getMessage());
+ }
+
+ @Test void a12_toMapOmitsValueWhenAbsent() {
+ assertEquals(Map.of("field", "n", "op", "present"), VisibilityRule.when("n").present().toMap());
+ assertEquals(Map.of("field", "s", "op", "in", "value", List.of("a")), VisibilityRule.when("s").in("a").toMap());
+ }
+
+ @Test void b01_rowActionSerializesVisibleWhen() {
+ var a = RowAction.create("escalate").visibleWhen(VisibilityRule.when("status").eq("open"));
+ var json = Json.of(a);
+ assertTrue(json.contains("visibleWhen"), json);
+ assertTrue(json.contains("\"op\":\"eq\""), json);
+ }
+
+ @Test void b02_rowActionOmitsVisibleWhenWhenUnset() {
+ assertFalse(Json.of(RowAction.create("x")).contains("visibleWhen"));
+ }
+
+ @Test void b03_regionAndFieldCarryVisibleWhenInContract() {
+ var rule = VisibilityRule.when("facts.admin").eq(true);
+ var region = RegionDef.create("r").visibleWhen(rule)
+ .fields(RegionDef.Field.of("a").visibleWhen(rule), RegionDef.Field.of("b"));
+ var m = region.toContractMap();
+ assertEquals(List.of(rule.toMap()), m.get("visibleWhen"));
+ var fields = (List<?>)m.get("fields");
+ assertEquals(List.of(rule.toMap()), ((Map<?,?>)fields.get(0)).get("visibleWhen"));
+ assertFalse(((Map<?,?>)fields.get(1)).containsKey("visibleWhen"));
+ }
+
+ @Test void b04_enabledWhenRejectsCollectionOps() {
+ assertThrows(IllegalArgumentException.class,
+ () -> org.apache.juneau.rest.server.views.RowActionEnabledRule.of("s", org.apache.juneau.rest.server.widgets.Op.IN, "x", "r"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/badges.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/badges.cjs
new file mode 100644
index 0000000000..70a96168f3
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/badges.cjs
@@ -0,0 +1,250 @@
+/*
+ * 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.
+ */
+
+/*
+ * badges.cjs - drives juneau-badges.js against a scripted fetch and a captured timer queue: label templates,
+ * hide-at-zero and pulse, 401/403 removal, stale + backoff, visibleWhen gating, scope resolution, drain-triggered
+ * card refresh, tooltip open/close, and click navigation. Prints one JSON report.
+ *
+ * Usage: node badges.cjs <juneau-badges.js>
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+const { makeConsoleEnv } = require(path.join(__dirname, 'console-dom-shim.cjs'));
+
+const badgesPath = process.argv[2];
+if (!badgesPath) {
+ console.error('usage: node badges.cjs <juneau-badges.js>');
+ process.exit(2);
+}
+const src = fs.readFileSync(badgesPath, 'utf8');
+
+/** Builds a fresh page + script instance. `responses` is a queue of {status, body|throws} consumed per fetch. */
+function setup(responses, opts) {
+ opts = opts || {};
+ const env = makeConsoleEnv(opts.location ? { location: opts.location } : undefined);
+ const doc = env.document;
+ const win = env.window;
+ win.window = win;
+ win.document = doc;
+ const errors = [], urls = [], assigned = [], timers = [], reloaded = [];
+ const header = doc.createElement('div');
+ header.className = 'jc-header-actions';
+ doc.body.appendChild(header);
+ win.location.assign = u => assigned.push(u);
+ if (opts.contract) {
+ win.JuneauConsole = {
+ contract: () => opts.contract,
+ refreshCard: id => { reloaded.push(id); return Promise.resolve(); }
+ };
+ }
+ if (opts.urlState) win.JuneauViews = { urlState: opts.urlState };
+ const sandbox = {
+ window: win, document: doc,
+ console: { error: m => errors.push(String(m)), log: () => {}, warn: () => {} },
+ URLSearchParams, Proxy, Promise, JSON, Object, Array, Error, String, Math, Set, Map,
+ encodeURIComponent,
+ fetch: u => {
+ urls.push(u);
+ const r = responses.shift();
+ if (!r) return Promise.reject(new Error('no scripted response'));
+ if (r.throws) return Promise.reject(new Error(r.throws));
+ return Promise.resolve({ status: r.status || 200, json: () => Promise.resolve(r.body) });
+ },
+ setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
+ clearTimeout: () => {}
+ };
+ vm.runInNewContext(src, sandbox, { filename: 'juneau-badges.js' });
+ return { env, doc, win, NS: win.JuneauConsoleBadges, errors, urls, assigned, timers, reloaded, header };
+}
+
+const flush = () => new Promise(r => setTimeout(r, 0));
+const badge = (doc, id) => doc.querySelector('[data-juneau-badge-id=' + id + ']');
+
+async function tick(h) {
+ const t = h.timers.pop();
+ t.fn();
+ await flush();
+ return t.ms;
+}
+
+(async () => {
+ const report = {};
+
+ // (a)-(c) labels, role/aria-live, singular, mine suffix
+ {
+ const h = setup([{ body: { total: 3 } }, { body: { total: 1 } }, { body: { total: 2, mine: 1 } }]);
+ h.NS.mount([{ id: 'pending', src: '/p' }]);
+ await flush();
+ const b = badge(h.doc, 'pending');
+ report.plainLabel = b.textContent;
+ report.role = b.getAttribute('role');
+ report.ariaLive = b.getAttribute('aria-live');
+ report.inHeaderBadges = b.parentNode.className;
+ report.defaultTone = b.getAttribute('data-juneau-badge-tone');
+ await tick(h);
+ report.singularLabel = b.textContent;
+ await tick(h);
+ report.mineLabel = b.textContent;
+ report.noPulseOnDecreaseOrFirst = true;
+ }
+
+ // custom label templates, tone mapping
+ {
+ const h = setup([{ body: { total: 2 } }]);
+ h.NS.mount([{ id: 'x', src: '/p', tone: 'danger', label: { other: '{total} open' } }]);
+ await flush();
+ const b = badge(h.doc, 'x');
+ report.customLabel = b.textContent;
+ report.dangerTone = b.getAttribute('data-juneau-badge-tone');
+ }
+
+ // (d) zero hides, then >0 un-hides and pulses
+ {
+ const h = setup([{ body: { total: 0 } }, { body: { total: 2 } }]);
+ h.NS.mount([{ id: 'z', src: '/p' }]);
+ await flush();
+ const b = badge(h.doc, 'z');
+ report.zeroHidden = b.hidden === true;
+ await tick(h);
+ report.reshown = b.hidden === false;
+ report.pulsed = b.classList.contains('jc-count-badge--pulse');
+ }
+
+ // (e) 403 removes the badge, logs E-JS-65 once, stops polling
+ {
+ const h = setup([{ status: 403, body: {} }]);
+ h.NS.mount([{ id: 'f', src: '/p' }]);
+ await flush();
+ report.removedOn403 = badge(h.doc, 'f') === null;
+ report.e65Count = h.errors.filter(e => e.includes('E-JS-65')).length;
+ report.noRescheduleOn403 = h.timers.length === 0;
+ report.fetchCountAfter403 = h.urls.length;
+ }
+
+ // (f) no numeric total: stale + E-JS-66 + backoff doubles
+ {
+ const h = setup([{ body: { total: 'x' } }, { body: {} }, { throws: 'down' }]);
+ h.NS.mount([{ id: 's', src: '/p', refreshMs: 10000 }]);
+ await flush();
+ const b = badge(h.doc, 's');
+ report.stale = b.classList.contains('jc-count-badge--stale');
+ report.e66 = h.errors.some(e => e.includes('E-JS-66'));
+ const d1 = await tick(h);
+ const d2 = await tick(h);
+ report.backoff = [d1, d2];
+ report.e65OnNetworkError = h.errors.some(e => e.includes('E-JS-65') && e.includes('down'));
+ }
+
+ // refreshMs below the floor is clamped when scheduling
+ {
+ const h = setup([{ body: { total: 1 } }]);
+ h.NS.mount([{ id: 'c', src: '/p', refreshMs: 100 }]);
+ await flush();
+ report.clampedInterval = h.timers[h.timers.length - 1].ms;
+ }
+
+ // visibleWhen: gated by facts, never fetched when hidden
+ {
+ const contract = { activeNav: ['a'], facts: { viewer: { roles: ['oncall'] } } };
+ const h = setup([{ body: { total: 1 } }], { contract });
+ h.NS.mount([
+ { id: 'yes', src: '/yes', visibleWhen: [{ field: 'viewer.roles', op: 'contains', value: 'oncall' }] },
+ { id: 'no', src: '/no', visibleWhen: { field: 'viewer.roles', op: 'contains', value: 'admin' } }
+ ]);
+ await flush();
+ report.visibleWhenShown = badge(h.doc, 'yes') !== null;
+ report.visibleWhenHiddenNotMounted = badge(h.doc, 'no') === null;
+ report.visibleWhenFetched = h.urls;
+ }
+
+ // scope: nav leaf resolves values onto the request; no match hides and keeps polling
+ {
+ const contract = { activeNav: ['rules', 'suspensions'], facts: {} };
+ const h = setup([{ body: { total: 2 } }], { contract });
+ h.NS.mount([{ id: 'sc', src: '/p', params: { a: 'b c' }, scope: { param: 'beanType', by: 'nav', values: { suspensions: ['Suspension', 'Hold'] } } }]);
+ await flush();
+ report.scopedUrl = h.urls[0];
+ const h2 = setup([], { contract: { activeNav: ['other'], facts: {} } });
+ h2.NS.mount([{ id: 'sc2', src: '/p', scope: { param: 'beanType', values: { suspensions: ['Suspension'] } } }]);
+ await flush();
+ report.unscopedPageNoFetch = h2.urls.length === 0;
+ report.unscopedPageReschedules = h2.timers.length === 1;
+ }
+
+ // drain detection refreshes linked cards, only on an item leaving
+ {
+ const contract = { activeNav: [], facts: {} };
+ const h = setup([
+ { body: { total: 2, items: [{ id: '1' }, { id: '2' }] } },
+ { body: { total: 3, items: [{ id: '1' }, { id: '2' }, { id: '3' }] } },
+ { body: { total: 2, items: [{ id: '2' }, { id: '3' }] } }
+ ], { contract });
+ h.NS.mount([{ id: 'd', src: '/p', refreshes: ['rules-table'] }]);
+ await flush();
+ await tick(h);
+ report.reloadedOnGrowth = h.reloaded.length;
+ await tick(h);
+ report.reloadedOnDrain = h.reloaded.slice();
+ }
+
+ // tooltip: opens on focus with the item cap, Escape and blur close it
+ {
+ const items = [];
+ for (let i = 1; i <= 4; i++) items.push({ id: String(i), label: 'item ' + i, href: '/i/' + i });
+ const h = setup([{ body: { total: 4, items } }]);
+ h.NS.mount([{ id: 't', src: '/p', tooltipMax: 3 }]);
+ await flush();
+ const b = badge(h.doc, 't');
+ b.dispatch('focus');
+ const pop = h.doc.querySelector('.jc-count-badge-popover');
+ report.tooltipOpen = pop !== null;
+ report.tooltipRole = pop && pop.getAttribute('role');
+ report.tooltipItems = pop ? pop.querySelectorAll('.jc-count-badge-popover-item').length : -1;
+ report.tooltipMore = pop ? pop.querySelector('.jc-count-badge-popover-more').textContent : null;
+ report.describedBy = b.getAttribute('aria-describedby') === (pop && pop.id);
+ h.env.dispatchDocument('keydown', { key: 'Escape' });
+ report.escapeCloses = h.doc.querySelector('.jc-count-badge-popover') === null;
+ b.dispatch('mouseenter');
+ const reopened = h.doc.querySelector('.jc-count-badge-popover') !== null;
+ b.dispatch('blur');
+ report.blurCloses = reopened && h.doc.querySelector('.jc-count-badge-popover') === null;
+ }
+
+ // (g)/(h) click navigation
+ {
+ const encoded = [];
+ const urlState = { encode: s => { encoded.push(s); return 'filter(beanType=' + s.filters[0].expr + ')'; } };
+ const contract = { activeNav: ['suspensions'], facts: {} };
+ const h = setup([{ body: { total: 1 } }, { body: { total: 1 } }], { contract, urlState });
+ h.NS.mount([
+ { id: 'plain', src: '/p', href: '/ui/changes' },
+ { id: 'filt', src: '/p', href: '/ui/changes', stateFilter: { column: 'beanType' }, scope: { param: 'beanType', values: { suspensions: ['Suspension'] } } }
+ ]);
+ await flush();
+ badge(h.doc, 'plain').dispatch('click');
+ badge(h.doc, 'filt').dispatch('click');
+ report.plainNav = h.assigned[0];
+ report.filteredNav = h.assigned[1];
+ report.encodeArg = encoded[0] && encoded[0].filters[0];
+ }
+
+ console.log(JSON.stringify(report));
+})().catch(e => { console.error(e && e.stack || e); process.exit(1); });
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/badges.html.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/badges.html.cjs
new file mode 100644
index 0000000000..fdbfe84a9d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/badges.html.cjs
@@ -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.
+ */
+/*
+ * badges.html.cjs - real-browser prober for the count badge: label and visibility in the header, the hover popover,
+ * click navigation (to a same-page hash, since location.assign is not stubbable in a real browser), and removal on 403.
+ *
+ * Never runs in a default build; it is driven by PendingBadge_BrowserTest, which only runs under `mvn -Pjs-tests`.
+ *
+ * Usage: node badges.html.cjs <page.html>
+ *
+ * This script only OBSERVES and prints ONE JSON object; every assertion lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+const PROBE = async function () {
+ const NS = window.JuneauConsoleBadges;
+ const out = { hasMount: typeof NS?.mount === 'function' };
+ if (!out.hasMount) return out;
+ const bodies = {
+ '/badges/pending': { status: 200, body: { total: 3, mine: 1, items: [{ id: '1', label: 'Rule one' }, { id: '2', label: 'Rule two' }] } },
+ '/badges/denied': { status: 403, body: {} }
+ };
+ window.fetch = function (url) {
+ const r = bodies[String(url).split('?')[0]] || { status: 404, body: {} };
+ return Promise.resolve({ status: r.status, json: function () { return Promise.resolve(r.body); } });
+ };
+ NS.mount([
+ { id: 'pending', src: '/badges/pending', href: '#changes', refreshMs: 60000 },
+ { id: 'denied', src: '/badges/denied' }
+ ]);
+ await new Promise(function (r) { setTimeout(r, 100); });
+ const b = document.querySelector('[data-juneau-badge-id=pending]');
+ if (b) {
+ const r = b.getBoundingClientRect();
+ out.label = b.textContent;
+ out.visible = r.width > 0 && r.height > 0;
+ out.hostClass = b.parentNode.className;
+ b.dispatchEvent(new MouseEvent('mouseenter'));
+ const pop = document.querySelector('.jc-count-badge-popover');
+ out.popoverItems = pop ? pop.querySelectorAll('.jc-count-badge-popover-item').length : -1;
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ out.popoverClosedOnEscape = document.querySelector('.jc-count-badge-popover') === null;
+ b.click();
+ out.hash = location.hash;
+ }
+ out.deniedRemoved = document.querySelector('[data-juneau-badge-id=denied]') === null;
+ NS.unmountAll();
+ return out;
+};
+
+(async () => {
+ const [fixture] = process.argv.slice(2);
+ if (!fixture) {
+ process.stderr.write('usage: node badges.html.cjs <page.html>\n');
+ process.exit(2);
+ }
+ if (!fs.existsSync(fixture))
+ throw new Error('fixture not found: ' + fixture);
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage();
+ const failures = [];
+ page.on('pageerror', e => failures.push(String(e)));
+ await page.goto('file://' + path.resolve(fixture));
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+ const report = await page.evaluate(PROBE);
+ report.jsFailures = failures.slice();
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+ } finally {
+ await browser.close();
+ }
+})().catch(error => {
+ process.stderr.write(String(error?.stack || error) + '\n');
+ process.exit(1);
+});
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/confirm-renderer.html.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/confirm-renderer.html.cjs
new file mode 100644
index 0000000000..5994486e76
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/confirm-renderer.html.cjs
@@ -0,0 +1,139 @@
+/*
+ * 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.
+ */
+/*
+ * confirm-renderer.html.cjs - real-browser prober for the confirm pipeline: a registered renderer paints its own
+ * modal and its real Confirm/Cancel buttons decide whether the row action's write is sent, and a danger confirm in the
+ * built-in modal focuses Cancel first.
+ *
+ * Never runs in a default build; it is driven by ConfirmRenderer_BrowserTest, which only runs under `mvn -Pjs-tests`.
+ *
+ * Usage: node confirm-renderer.html.cjs <page.html>
+ *
+ * This script only OBSERVES and prints ONE JSON object; every assertion lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+const PROBE = async function () {
+ const NS = window.JuneauViews;
+ const out = { hasRun: typeof NS?.rowActions?.run === 'function' };
+ if (!out.hasRun) return out;
+
+ const fetchCalls = [];
+ window.fetch = function (url, opts) { fetchCalls.push({ url: url, opts: opts }); return Promise.resolve({ ok: true, status: 200 }); };
+
+ // NOSONAR javascript:S7721 -- must stay nested: page.evaluate(PROBE) ships only PROBE's own source into the browser context.
+ const flush = function () { return new Promise(function (r) { setTimeout(r, 20); }); }; // NOSONAR javascript:S7721 -- must stay nested: page.evaluate(PROBE) serializes only PROBE's own source
+
+ // A renderer that paints its own styled modal; its real buttons settle the confirm.
+ NS.rowActions.registerConfirmRenderer('styled', function (modal) {
+ return new Promise(function (resolve) {
+ const box = document.createElement('div');
+ box.id = 'styled-modal';
+ box.style.cssText = 'position:fixed;top:20px;left:20px;width:240px;height:80px;background:#eee;z-index:5000';
+ box.textContent = modal.title;
+ const ok = document.createElement('button');
+ ok.id = 'styled-ok';
+ ok.textContent = modal.confirmLabel;
+ const cancel = document.createElement('button');
+ cancel.id = 'styled-cancel';
+ cancel.textContent = modal.cancelLabel;
+ function done(v) { box.remove(); resolve(v); }
+ ok.addEventListener('click', function () { done(true); });
+ cancel.addEventListener('click', function () { done(false); });
+ box.appendChild(ok);
+ box.appendChild(cancel);
+ document.body.appendChild(box);
+ });
+ });
+
+ const action = { id: 'delete', label: 'Delete', endpoint: '/x/{id}', method: 'DELETE', present: 'dialog',
+ confirm: 'Delete {name}?', confirmLabel: 'Delete it', tone: 'danger', confirmRenderer: 'styled' };
+ const table = document.createElement('table');
+ table.dataset.juneauView = 'v';
+ table.dataset.juneauCsrf = 'tok-1';
+ const tbody = document.createElement('tbody');
+ const tr = document.createElement('tr');
+ tr.dataset.juneauRowId = '7';
+ const td = document.createElement('td');
+ const btn = document.createElement('button');
+ btn.textContent = 'go';
+ td.appendChild(btn);
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+ table.appendChild(tbody);
+ document.body.appendChild(table);
+ table.__juneauCtx = { viewDef: { id: 'v', rowActions: [action] }, dataTable: { row: function () { return { data: function () { return { id: 7, name: 'Widget' }; } }; } } };
+
+ NS.rowActions.run(btn, 'delete');
+ await flush();
+ const modal = document.getElementById('styled-modal');
+ out.styledText = modal?.firstChild?.textContent ?? null;
+ out.styledOkLabel = document.getElementById('styled-ok')?.textContent ?? null;
+ const r = modal ? modal.getBoundingClientRect() : null;
+ out.styledVisible = !!r && r.width > 0 && r.height > 0;
+ out.fetchesBeforeClick = fetchCalls.length;
+ document.getElementById('styled-ok').click();
+ await flush();
+ out.confirmFetches = fetchCalls.length;
+ out.confirmMethod = fetchCalls[0]?.opts.method ?? null;
+ out.confirmUrl = fetchCalls[0]?.url ?? null;
+
+ NS.rowActions.run(btn, 'delete');
+ await flush();
+ document.getElementById('styled-cancel').click();
+ await flush();
+ out.fetchesAfterCancel = fetchCalls.length;
+
+ // The built-in modal: a danger confirm focuses Cancel first.
+ const pending = NS.dialogs.confirm({ title: 'Really?', tone: 'danger', confirmLabel: 'Do it' });
+ await flush();
+ out.dangerCancelFocused = document.activeElement?.classList.contains('juneau-view-dialog-cancel') === true;
+ document.querySelector('.juneau-view-dialog-confirm').click();
+ out.builtInResolved = await pending;
+ return out;
+};
+
+(async () => {
+ const [fixture] = process.argv.slice(2);
+ if (!fixture) {
+ process.stderr.write('usage: node confirm-renderer.html.cjs <page.html>\n');
+ process.exit(2);
+ }
+ if (!fs.existsSync(fixture))
+ throw new Error('fixture not found: ' + fixture);
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage();
+ const failures = [];
+ page.on('pageerror', e => failures.push(String(e)));
+ page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+ await page.goto('file://' + path.resolve(fixture));
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+ const report = await page.evaluate(PROBE);
+ report.jsFailures = failures.slice();
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+ } finally {
+ await browser.close();
+ }
+})().catch(error => {
+ process.stderr.write(String(error?.stack || error) + '\n');
+ process.exit(1);
+});
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/confirm.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/confirm.cjs
new file mode 100644
index 0000000000..7207fdcd08
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/confirm.cjs
@@ -0,0 +1,90 @@
+/*
+ * 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.
+ */
+/*
+ * confirm.cjs - always-on Node harness for JuneauViews.dialogs.confirm: a registered renderer decides the answer,
+ * an unregistered renderer name rejects with E-JS-71, and the built-in modal lists/caps items, carries confirmLabel
+ * and tone, focuses Cancel first for a danger confirm, runs a function `action` only on confirm, and resolves false on
+ * cancel. Every assertion lives in the Java test.
+ *
+ * Usage: node confirm.cjs <juneau-renders.js> <juneau-views.js>
+ */
+'use strict';
+
... 2793 lines suppressed ...