feat(help): qbit-edi help bundle via QQQ help bundles, with coverage gate #19

Merged
KofTwentyTwo merged 1 commits from feature/help-bundle into develop 2026-10-15 01:45:31 +00:00
8 changed files with 1943 additions and 160 deletions

No files matched your search

+3 -1
View File
@@ -6,11 +6,13 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
### Changed
- Require QQQ 4.1.0-RC.7 for core help-bundle registration and coverage validation.
- X12 routing and sending honor identity `isActive`, matching EDIFACT.
### Added
- Help bundle `help/qbit-edi.yaml` (stand-in for qqq#1198): every table and the app say what they are for and how rows get created, including the setup order, so an empty table explains itself.
- Complete, host-neutral `help/qbit-edi.yaml` bundle registered with QQQ core: app, tables, fields, actions, widgets and possible-value sources, with a coverage gate and HOST overrides. Table/app fallback remains available without `helpContent`.
- Setup status section on the EDI home (partner, identities, agreement, agreement documents, transport, certificates, guides, maps) with the next step; it counts only the active rows the runtime uses.
- "Install sample maps" action on Maps: standard streamed ETL, insert-only by name, installs the 32 bundled synthetic maps as draft, unbound maps bound to the built-in guide.
- `EdiTablesGuardrailTest`: every table is used at runtime, seeded or listed as starting empty, and has help.
+18 -1
View File
@@ -34,7 +34,7 @@ ALTER TABLE edi_agreement ADD COLUMN syntax_identifier VARCHAR(255);
## Requirements and installation
Java 21, QQQ **4.1.0-RC.6+**, an RDBMS backend, and `com.kingsrook.qbits:qbit-edi:0.1.0-SNAPSHOT`.
Java 21, QQQ **4.1.0-RC.7+**, an RDBMS backend, and `com.kingsrook.qbits:qbit-edi:0.1.0-SNAPSHOT`.
Apply [the PostgreSQL schema](docs/ddl/edi-postgres.sql). Targets and their associations must
share the EDI backend so each transaction set can commit or roll back atomically.
@@ -60,6 +60,23 @@ The `edi` app has Activity/Setup sections, typed home widgets, record screens an
Review/Result actions. Main tables have RC.6 search-field metadata; `EdiIntegrations.mainTables()`
also exposes the list for host search registration.
## In-app help
The producer registers [help/qbit-edi.yaml](src/main/resources/help/qbit-edi.yaml), a `QBIT`
bundle versioned with this artifact. It covers the EDI app, tables, fields, actions, widgets,
and possible-value sources. Live `{{...}}` references follow the current metadata.
Hosts can register their own `layer: HOST` bundle with `HelpBundles.register(qInstance,
"help/my-host.yaml")`, using the same keys and roles to override selected entries. Enable
core's `helpContent` provider with provenance fields, then call
`QInstanceHelpContentManager.loadHelpContent(qInstance)` and `HelpBundles.syncOnStartup(qInstance)`
after defining metadata. Core preserves user edits during sync. If you configure a different
EDI app name, use that name in your host's app help key.
Registration is safe without `helpContent`; those hosts retain the table/app help fallback.
`EdiHelpBundleTest` runs the core `HelpBundleAssertions` coverage gate and checks widget and
possible-value-source coverage during `./mvnw verify`, without host-specific exclusions.
## Tables
There are **26 persisted tables**, whose SQL names equal their QQQ names.
+1 -1
View File
@@ -213,7 +213,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.edi;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
@@ -130,7 +131,16 @@ public class EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(instance);
output.addSelfToInstance(instance);
EdiRuntime.install(instance, qBitConfig);
com.kingsrook.qbits.edi.metadata.EdiHelp.apply(instance, qBitConfig.getAppName());
// Registration is metadata-only; core startup sync skips hosts without helpContent.
HelpBundles.register(instance, "help/qbit-edi.yaml");
// The app name is configurable; its bundle key must follow the installed app.
HelpBundles.of(instance).getBundles().getLast().getEntries().stream()
.filter(entry -> "app:edi;slot:help".equals(entry.getKey()))
.forEach(entry -> entry.setKey("app:" + qBitConfig.getAppName() + ";slot:help"));
if(instance.getTable("helpContent") == null)
{
com.kingsrook.qbits.edi.metadata.EdiHelp.apply(instance, qBitConfig.getAppName());
}
instance.withSupplementalMetaData(new com.kingsrook.qbits.edi.metadata.EdiRecordLabels());
instance.withRecordChangeListener(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.flow.OutboundEnqueue.class));
instance.withSupplementalMetaData(new com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData()
@@ -21,16 +21,14 @@
package com.kingsrook.qbits.edi.metadata;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
@@ -39,8 +37,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
** Applies help/qbit-edi.yaml (the QQQ help-bundle format) to the Edi app
** and its edi_* tables: what each table is for and how its rows get created,
** shown on the table screen and when it is empty.
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned QQQ
** release includes it; the bundle file stays the same.
** Fallback for hosts without the helpContent table. Hosts with that table use
** the core bundle registry and startup sync, including HOST and USER overrides.
*******************************************************************************/
public final class EdiHelp
{
@@ -64,33 +62,13 @@ public final class EdiHelp
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = EdiHelp.class.getResourceAsStream(RESOURCE))
Map<String, JsonNode> entries = new LinkedHashMap<>();
ObjectMapper mapper = new ObjectMapper();
for(var entry : HelpBundles.load("help/qbit-edi.yaml", EdiHelp.class.getClassLoader()).getEntries())
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-edi".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
entries.put(entry.getKey(), mapper.valueToTree(entry));
}
return (entries);
}
@@ -121,11 +99,11 @@ public final class EdiHelp
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
** Preserve the tooltip summary separately from the Markdown long help.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
return (new QHelpContent().withSummary(entry.path("summary").asText().strip()).withContentAsMarkdown(entry.path("content").asText().strip()));
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,88 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi;
import com.kingsrook.qqq.backend.core.actions.help.HelpTargetCatalog;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Coverage of the qbit's own help, independent of host integrations.
*******************************************************************************/
class EdiHelpBundleTest
{
/*******************************************************************************
** Every owned target resolves and has help, even without a help table.
*******************************************************************************/
@Test
void bundleIsComplete() throws Exception
{
QInstance instance = new QInstance();
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
new EdiQBitProducer().withQBitConfig(new EdiQBitConfig().withBackendName("memory")).produce(instance);
assertThat(instance.getTable("helpContent")).isNull();
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-edi");
assertThat(bundle.getVersion()).isEqualTo("0.1.0-SNAPSHOT");
});
HelpBundles.syncOnStartup(instance);
HelpBundleAssertions.forInstance(instance)
.bundles("help/qbit-edi.yaml")
.owning(target -> target.name().equals("edi") || target.name().startsWith("edi_"))
.assertComplete();
var keys = HelpBundles.of(instance).getBundles().getFirst().getEntries().stream().map(entry -> entry.getKey()).toList();
// Core's gate currently requires apps, tables, processes and fields; also cover our widgets and value sources.
for(var target : HelpTargetCatalog.collect(instance))
{
if(target.name().startsWith("edi_") && java.util.List.of("widget", "possibleValueSource").contains(target.kind()))
{
assertThat(keys).as(target.key()).contains(target.key());
}
}
}
/*******************************************************************************
** Help-enabled hosts use core sync, and a configured app name is respected.
*******************************************************************************/
@Test
void registersForHelpEnabledHost() throws Exception
{
QInstance instance = new QInstance();
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
instance.addTable(new com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData().withName("helpContent"));
new EdiQBitProducer().withQBitConfig(new EdiQBitConfig().withBackendName("memory").withAppName("trading")).produce(instance);
var entries = HelpBundles.of(instance).getBundles().getFirst().getEntries();
assertThat(entries).anyMatch(entry -> "app:trading;slot:help".equals(entry.getKey()))
.noneMatch(entry -> "app:edi;slot:help".equals(entry.getKey()));
assertThat(instance.getTable("edi_partner").getHelpContent()).isNullOrEmpty();
assertThat(instance.getApp("trading").getHelpContent()).isNullOrEmpty();
}
}
@@ -181,7 +181,6 @@ class EdiTablesGuardrailTest
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
}
assertThat(instance.getApp(EdiRuntime.current().config().getAppName()).getHelpContent()).containsKey(EdiHelp.SLOT);
assertThat(EdiHelp.entries().keySet()).allMatch(key -> key.startsWith("app:") || instance.getTable(key.substring(6, key.indexOf(';'))) != null, "help names only real tables");
assertThat(EdiHelp.entries().get("table:edi_map;slot:help").path("content").asText()).contains("Install sample maps");
assertThat(EdiHelp.entries().get("app:edi;slot:help").path("content").asText().indexOf("Partner")).isLessThan(EdiHelp.entries().get("app:edi;slot:help").path("content").asText().indexOf("Transport"));
}