Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5383718a3
|
No files matched your search
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased / 0.2.0
|
||||
|
||||
- Adopt QQQ 4.1.0-RC.7 native help bundles, replacing the RC.6 stand-in loader with host-neutral CRM help, live references and the core coverage gate.
|
||||
|
||||
- Upgrade to Java 21, QQQ 4.1.0-RC.6 and qbit-build-parent 2.1.2.
|
||||
- Add Maven wrapper, CI configuration, architecture guardrails and coverage gates.
|
||||
- Rename tables, joins, widgets and CRM processes to `crm_` snake_case, with inferred snake_case SQL columns.
|
||||
|
||||
@@ -174,6 +174,24 @@ Later phases complete the following behavior:
|
||||
|
||||
The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and the remaining P2 security/integration work are not part of this phase.
|
||||
|
||||
## In-app help
|
||||
|
||||
CRM registers [help/qbit-crm.yaml](src/main/resources/help/qbit-crm.yaml) from
|
||||
`CrmQBitProducer`. The bundle uses the `QBIT` layer and the current artifact version.
|
||||
Registration only adds metadata and works without a `helpContent` table; QQQ's
|
||||
startup sync skips hosts without that table. Hosts with help storage load help
|
||||
and call `HelpBundles.syncOnStartup(qInstance)` after defining their metadata.
|
||||
|
||||
To customize wording or describe your deployment, register a separate `HOST`
|
||||
bundle with the same entry keys (and roles). HOST text takes precedence over QBIT
|
||||
text; the sync preserves user edits for review. Keep live `{{...}}` references
|
||||
when referring to labels, defaults or enum values.
|
||||
|
||||
`CrmPublicationHelpTest` runs `HelpBundleAssertions` against the produced and
|
||||
validated CRM instance, including generated record actions, and also requires
|
||||
widget and possible-value help. `CrmTablesGuardrailTest` checks every table's
|
||||
row-creation guidance. Run both through `./mvnw -q verify`.
|
||||
|
||||
## Source layout
|
||||
|
||||
`CrmQBitProducer` discovers entity, process and widget producers. `core/` holds contacts, companies, forms, lists and consent; `deals/`, `activities/`, `email/` and `scoring/` hold their respective metadata and behavior. `actions/` defines the streamed record actions and their inputs/summaries. `security/` defines permissions, `sync/` holds external mappings and currency utilities, and `widgets/` contains widget producers/renderers. SQL is shipped under `src/main/resources/db/liquibase/qbit-crm/`.
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<revision>0.2.0-RC.4</revision>
|
||||
<revision>0.2.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
@@ -204,14 +204,14 @@
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
|
||||
<!-- imports the released 4.1 BOM, this profile is active by default and -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<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>
|
||||
|
||||
@@ -43,7 +43,6 @@ import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealProduct;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity;
|
||||
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
|
||||
import com.kingsrook.qbits.crm.scoring.AutoAssignCustomizer;
|
||||
import com.kingsrook.qbits.crm.security.CrmDataPolicy;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
@@ -66,6 +65,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordOwnership;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
@@ -384,7 +384,8 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
}
|
||||
qInstance.withProcessLifecycleListener(new QCodeReference(CrmEmailProcessIdentity.class));
|
||||
registerHooks(qInstance);
|
||||
CrmHelpBundleStandIn.apply(output);
|
||||
// Registration is metadata-only; core startup sync guards hosts without helpContent.
|
||||
HelpBundles.register(qInstance, "help/qbit-crm.yaml");
|
||||
return (output);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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.crm.help;
|
||||
|
||||
|
||||
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.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9)
|
||||
** Load the future bundle format with core's Jackson YAML parser. RC.6 lacks
|
||||
** process/PVS help slots: action content is applied to frontend steps instead;
|
||||
** enum entries remain in the bundle for the central loader.
|
||||
*******************************************************************************/
|
||||
public final class CrmHelpBundleStandIn
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmHelpBundleStandIn()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the resource eagerly so malformed shipped help fails production.
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = CrmHelpBundleStandIn.class.getResourceAsStream("/help/qbit-crm.yaml"))
|
||||
{
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-crm".equals(bundle.path("bundle").asText()) || bundle.path("version").asInt() != 1
|
||||
|| !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("Invalid CRM bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
if(key.isBlank() || entry.path("summary").asText().isBlank() || entry.path("summary").asText().length() > 120
|
||||
|| entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("Invalid or duplicate help entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | IllegalArgumentException e)
|
||||
{
|
||||
throw (new QException("Could not load help/qbit-crm.yaml", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply help to the objects produced by this QBit only.
|
||||
*******************************************************************************/
|
||||
public static void apply(MetaDataProducerMultiOutput output) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : output.getEach(QTableMetaData.class))
|
||||
{
|
||||
table.withHelpContent("help", content(entries, "table:" + table.getName() + ";slot:help"));
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
String key = "table:" + table.getName() + ";field:" + field.getName();
|
||||
if(entries.containsKey(key))
|
||||
{
|
||||
field.withHelpContent(content(entries, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(QProcessMetaData process : output.getEach(QProcessMetaData.class))
|
||||
{
|
||||
QHelpContent help = content(entries, "process:" + process.getName() + ";slot:help");
|
||||
for(var step : process.getStepList())
|
||||
{
|
||||
if(step instanceof QFrontendStepMetaData frontend)
|
||||
{
|
||||
frontend.withHelpContent(help.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(QAppMetaData app : output.getEach(QAppMetaData.class))
|
||||
{
|
||||
app.withHelpContent("help", content(entries, "app:" + app.getName() + ";slot:help"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6 has no summary property, so retain the summary in the bundle only.
|
||||
*******************************************************************************/
|
||||
private static QHelpContent content(Map<String, JsonNode> entries, String key) throws QException
|
||||
{
|
||||
if(!entries.containsKey(key))
|
||||
{
|
||||
throw (new QException("Missing CRM help entry: " + key));
|
||||
}
|
||||
return (new QHelpContent().withContentAsMarkdown(entries.get(key).get("content").asText()));
|
||||
}
|
||||
}
|
||||
+2126
-1470
File diff suppressed because it is too large.
Load diff
@@ -23,7 +23,6 @@ package com.kingsrook.qbits.crm;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
|
||||
import com.kingsrook.qqq.api.actions.GetTableApiFieldsAction;
|
||||
import com.kingsrook.qqq.api.model.APIVersion;
|
||||
import com.kingsrook.qqq.api.model.actions.GetTableApiFieldsInput;
|
||||
@@ -33,8 +32,8 @@ import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -101,52 +100,29 @@ class CrmPublicationHelpTest extends BaseTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Help covers app every table main field process and enum.
|
||||
** Native registration is safe without help tables and covers all CRM metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void helpCoversAppEveryTableMainFieldProcessAndEnum() throws Exception
|
||||
void helpCoversOwnedMetadataWithoutHelpTable()
|
||||
{
|
||||
var entries = CrmHelpBundleStandIn.entries();
|
||||
var instance = QContext.getQInstance();
|
||||
assertThat(entries).containsKey("app:crm;slot:help");
|
||||
for(var table : instance.getTables().values())
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(entries).containsKey("table:" + table.getName() + ";slot:help");
|
||||
assertThat(table.getHelpContent().get("help")).isNotEmpty();
|
||||
if(CrmAppMetaData.MAIN_TABLES.contains(table.getName()))
|
||||
{
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
assertThat(entries).containsKey("table:" + table.getName() + ";field:" + field.getName());
|
||||
assertThat(field.getHelpContents()).as(table.getName() + "." + field.getName()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var process : instance.getProcesses().values())
|
||||
{
|
||||
if(process.getName().startsWith("crm_"))
|
||||
{
|
||||
assertThat(entries).containsKey("process:" + process.getName() + ";slot:help");
|
||||
// Core creates dotted bulk-process names after QBit production; they retain core screen help.
|
||||
if(!process.getName().contains("."))
|
||||
{
|
||||
process.getStepList().stream().filter(QFrontendStepMetaData.class::isInstance).map(QFrontendStepMetaData.class::cast)
|
||||
.forEach(step -> assertThat(step.getHelpContents()).isNotEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var pvs : instance.getPossibleValueSources().values())
|
||||
{
|
||||
if(pvs.getType() == QPossibleValueSourceType.ENUM && pvs.getName().startsWith("Crm"))
|
||||
{
|
||||
assertThat(entries).containsKey("possibleValueSource:" + pvs.getName() + ";slot:help");
|
||||
}
|
||||
}
|
||||
entries.values().forEach(entry ->
|
||||
{
|
||||
assertThat(entry.get("summary").asText()).hasSizeLessThanOrEqualTo(120).isNotBlank();
|
||||
assertThat(entry.get("content").asText()).isNotBlank();
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-crm");
|
||||
assertThat(bundle.getVersion()).isEqualTo(CrmQBitProducer.VERSION);
|
||||
assertThat(bundle.getLayer().name()).isEqualTo("QBIT");
|
||||
});
|
||||
HelpBundles.syncOnStartup(instance);
|
||||
HelpBundleAssertions.forInstance(instance)
|
||||
.bundles("help/qbit-crm.yaml")
|
||||
.owning(target -> target.name().equals("crm") || target.name().startsWith("crm_") || target.name().startsWith("Crm"))
|
||||
.assertComplete();
|
||||
// Core's gate validates these entries but does not require widget or possible-value coverage.
|
||||
var entries = HelpBundles.of(instance).resolveWinningEntries();
|
||||
instance.getWidgets().keySet().stream().filter(name -> name.startsWith("crm_")).forEach(name ->
|
||||
assertThat(entries).containsKey(new HelpBundles.EntryKey("widget:" + name + ";slot:label", "ALL_SCREENS")));
|
||||
instance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("crm_") || name.startsWith("Crm")).forEach(name ->
|
||||
assertThat(entries).containsKey(new HelpBundles.EntryKey("possibleValueSource:" + name + ";slot:help", "ALL_SCREENS")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
|
||||
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.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -160,18 +159,17 @@ class CrmTablesGuardrailTest extends BaseTest
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
assertThat(CrmHelpBundleStandIn.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
HelpBundles.of(qInstance()).getBundles().getFirst().getEntries()
|
||||
.forEach(entry -> assertThat(entry.getSummary()).as(entry.getKey()).hasSizeLessThanOrEqualTo(120));
|
||||
for(String table : crmTables())
|
||||
{
|
||||
List<QHelpContent> help = qInstance().getTable(table).getHelpContent() == null ? null : qInstance().getTable(table).getHelpContent().get("help");
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("**How rows get created:**");
|
||||
assertThat(tableHelp(table)).as(table + " help says how rows get created").contains("**How rows get created:**");
|
||||
}
|
||||
for(String table : NOT_WRITTEN_YET)
|
||||
{
|
||||
assertThat(qInstance().getTable(table).getHelpContent().get("help").get(0).getContent()).as(table + " says it is not used yet").contains("not used yet");
|
||||
assertThat(tableHelp(table)).as(table + " says it is not used yet").contains("not used yet");
|
||||
}
|
||||
assertThat(qInstance().getApp("crm").getHelpContent()).containsKey("help");
|
||||
assertThat(HelpBundles.of(qInstance()).resolveWinningEntries()).containsKey(new HelpBundles.EntryKey("app:crm;slot:help", "ALL_SCREENS"));
|
||||
}
|
||||
|
||||
|
||||
@@ -184,14 +182,26 @@ class CrmTablesGuardrailTest extends BaseTest
|
||||
{
|
||||
for(String table : CrmDefaultsChangelogTest.SEEDED_TABLES)
|
||||
{
|
||||
assertThat(qInstance().getTable(table).getHelpContent().get("help").get(0).getContent()).as(table).contains("seeds");
|
||||
assertThat(tableHelp(table)).as(table).contains("seeds");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** Read the winning table entry from the registered native bundle.
|
||||
*******************************************************************************/
|
||||
private String tableHelp(String table)
|
||||
{
|
||||
var entry = HelpBundles.of(qInstance()).resolveWinningEntries().get(new HelpBundles.EntryKey("table:" + table + ";slot:help", "ALL_SCREENS"));
|
||||
assertThat(entry).as(table + " table help").isNotNull();
|
||||
return (entry.entry().getContent());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test instance after CRM production.
|
||||
*******************************************************************************/
|
||||
private com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user