From 00a3f6632c526108c430ba2d0610f3e17e22c3ac Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 14 Jun 2024 08:56:48 -0500 Subject: [PATCH 1/4] CE-1113 Add ApiFieldCustomValueMapperBulkSupportInterface --- .../qqq/api/actions/ApiImplementation.java | 12 +-- .../qqq/api/actions/QRecordApiAdapter.java | 71 +++++++++++++++++- ...CustomValueMapperBulkSupportInterface.java | 45 +++++++++++ .../api/actions/ApiImplementationTest.java | 74 +++++++++++++++++++ 4 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/actions/ApiFieldCustomValueMapperBulkSupportInterface.java diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/ApiImplementation.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/ApiImplementation.java index 84137b7d..84105d4e 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/ApiImplementation.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/ApiImplementation.java @@ -380,11 +380,7 @@ public class ApiImplementation // map record fields for api // // note - don't put them in the output until after the count, just because that looks a little nicer, i think // //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - ArrayList> records = new ArrayList<>(); - for(QRecord record : queryOutput.getRecords()) - { - records.add(QRecordApiAdapter.qRecordToApiMap(record, tableName, apiName, version)); - } + ArrayList> records = QRecordApiAdapter.qRecordsToApiMapList(queryOutput.getRecords(), tableName, apiName, version); ///////////////////////////// // optionally do the count // @@ -615,7 +611,7 @@ public class ApiImplementation + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey)); } - Map outputRecord = QRecordApiAdapter.qRecordToApiMap(record, tableName, apiInstanceMetaData.getName(), version); + Map outputRecord = QRecordApiAdapter.qRecordsToApiMapList(List.of(record), tableName, apiInstanceMetaData.getName(), version).get(0); return (outputRecord); } @@ -1144,8 +1140,8 @@ public class ApiImplementation ApiProcessOutputInterface output = apiProcessMetaData.getOutput(); if(output != null) { - Serializable outputForProcess = output.getOutputForProcess(runProcessInput, runProcessOutput); - HttpApiResponse httpApiResponse = new HttpApiResponse(output.getSuccessStatusCode(runProcessInput, runProcessOutput), outputForProcess); + Serializable outputForProcess = output.getOutputForProcess(runProcessInput, runProcessOutput); + HttpApiResponse httpApiResponse = new HttpApiResponse(output.getSuccessStatusCode(runProcessInput, runProcessOutput), outputForProcess); output.customizeHttpApiResponse(httpApiResponse, runProcessInput, runProcessOutput); return httpApiResponse; } diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java index da2e211a..07cee3a7 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java @@ -33,6 +33,7 @@ import com.kingsrook.qqq.api.javalin.QBadRequestException; import com.kingsrook.qqq.api.model.APIVersion; import com.kingsrook.qqq.api.model.APIVersionRange; import com.kingsrook.qqq.api.model.actions.ApiFieldCustomValueMapper; +import com.kingsrook.qqq.api.model.actions.ApiFieldCustomValueMapperBulkSupportInterface; import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaData; import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataContainer; import com.kingsrook.qqq.api.model.metadata.fields.ApiFieldMetaData; @@ -67,9 +68,61 @@ public class QRecordApiAdapter /******************************************************************************* - ** Convert a QRecord to a map for the API + ** Simple/short form of convert a QRecord to a map for the API - e.g., meant for + ** public consumption. *******************************************************************************/ public static Map qRecordToApiMap(QRecord record, String tableName, String apiName, String apiVersion) throws QException + { + return qRecordsToApiMapList(List.of(record), tableName, apiName, apiVersion).get(0); + } + + + + /******************************************************************************* + ** bulk-version of the qRecordToApiMap - will use + ** ApiFieldCustomValueMapperBulkSupportInterface's in the bulky way. + *******************************************************************************/ + public static ArrayList> qRecordsToApiMapList(List records, String tableName, String apiName, String apiVersion) throws QException + { + Map fieldValueMappers = new HashMap<>(); + + List tableApiFields = GetTableApiFieldsAction.getTableApiFieldList(new GetTableApiFieldsAction.ApiNameVersionAndTableName(apiName, apiVersion, tableName)); + for(QFieldMetaData field : tableApiFields) + { + ApiFieldMetaData apiFieldMetaData = ObjectUtils.tryAndRequireNonNullElse(() -> ApiFieldMetaDataContainer.of(field).getApiFieldMetaData(apiName), new ApiFieldMetaData()); + String apiFieldName = ApiFieldMetaData.getEffectiveApiFieldName(apiName, field); + + if(apiFieldMetaData.getCustomValueMapper() != null) + { + if(!fieldValueMappers.containsKey(apiFieldMetaData.getCustomValueMapper().getName())) + { + ApiFieldCustomValueMapper customValueMapper = QCodeLoader.getAdHoc(ApiFieldCustomValueMapper.class, apiFieldMetaData.getCustomValueMapper()); + fieldValueMappers.put(apiFieldMetaData.getCustomValueMapper().getName(), customValueMapper); + + if(customValueMapper instanceof ApiFieldCustomValueMapperBulkSupportInterface bulkMapper) + { + bulkMapper.prepareToProduceApiValues(records); + } + } + } + } + + ArrayList> rs = new ArrayList<>(); + for(QRecord record : records) + { + rs.add(QRecordApiAdapter.qRecordToApiMap(record, tableName, apiName, apiVersion, fieldValueMappers)); + } + + return (rs); + } + + + + /******************************************************************************* + ** private version of convert a QRecord to a map for the API - takes params to + ** support working in bulk w/ customizers much better. + *******************************************************************************/ + private static Map qRecordToApiMap(QRecord record, String tableName, String apiName, String apiVersion, Map fieldValueMappers) throws QException { if(record == null) { @@ -87,14 +140,25 @@ public class QRecordApiAdapter ApiFieldMetaData apiFieldMetaData = ObjectUtils.tryAndRequireNonNullElse(() -> ApiFieldMetaDataContainer.of(field).getApiFieldMetaData(apiName), new ApiFieldMetaData()); String apiFieldName = ApiFieldMetaData.getEffectiveApiFieldName(apiName, field); - Serializable value = null; + Serializable value; if(StringUtils.hasContent(apiFieldMetaData.getReplacedByFieldName())) { value = record.getValue(apiFieldMetaData.getReplacedByFieldName()); } else if(apiFieldMetaData.getCustomValueMapper() != null) { - ApiFieldCustomValueMapper customValueMapper = QCodeLoader.getAdHoc(ApiFieldCustomValueMapper.class, apiFieldMetaData.getCustomValueMapper()); + if(fieldValueMappers == null) + { + fieldValueMappers = new HashMap<>(); + } + + String customValueMapperName = apiFieldMetaData.getCustomValueMapper().getName(); + if(!fieldValueMappers.containsKey(customValueMapperName)) + { + fieldValueMappers.put(customValueMapperName, QCodeLoader.getAdHoc(ApiFieldCustomValueMapper.class, apiFieldMetaData.getCustomValueMapper())); + } + + ApiFieldCustomValueMapper customValueMapper = fieldValueMappers.get(customValueMapperName); value = customValueMapper.produceApiValue(record, apiFieldName); } else @@ -331,5 +395,4 @@ public class QRecordApiAdapter return (null); } - } diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/actions/ApiFieldCustomValueMapperBulkSupportInterface.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/actions/ApiFieldCustomValueMapperBulkSupportInterface.java new file mode 100644 index 00000000..703b2a7e --- /dev/null +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/actions/ApiFieldCustomValueMapperBulkSupportInterface.java @@ -0,0 +1,45 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.api.model.actions; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** optional interface that can be added to an ApiFieldCustomValueMapper to + ** signal that the customizer knows how to work in bulk. + ** + ** e.g., given a list of records, do a bulk query for data; memoize that data; + ** then use it in multiple calls to produceApiValue, without, e.g., going back to + ** a backend to fetch data. + *******************************************************************************/ +public interface ApiFieldCustomValueMapperBulkSupportInterface +{ + + /******************************************************************************* + ** + *******************************************************************************/ + void prepareToProduceApiValues(List records); + +} diff --git a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/actions/ApiImplementationTest.java b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/actions/ApiImplementationTest.java index a55b2027..376562c2 100644 --- a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/actions/ApiImplementationTest.java +++ b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/actions/ApiImplementationTest.java @@ -26,12 +26,14 @@ import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDate; import java.time.Month; +import java.util.Collections; import java.util.List; import java.util.Map; import com.kingsrook.qqq.api.BaseTest; import com.kingsrook.qqq.api.TestUtils; import com.kingsrook.qqq.api.javalin.QBadRequestException; import com.kingsrook.qqq.api.model.actions.ApiFieldCustomValueMapper; +import com.kingsrook.qqq.api.model.actions.ApiFieldCustomValueMapperBulkSupportInterface; import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaData; import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataContainer; import com.kingsrook.qqq.api.model.metadata.fields.ApiFieldMetaData; @@ -197,6 +199,43 @@ class ApiImplementationTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBulkValueCustomizer() throws QException + { + QInstance qInstance = QContext.getQInstance(); + ApiInstanceMetaData apiInstanceMetaData = ApiInstanceMetaDataContainer.of(qInstance).getApiInstanceMetaData(TestUtils.API_NAME); + TestUtils.insertSimpsons(); + + //////////////////////////////////////////////////////////////////// + // set up a custom value mapper on lastName field of person table // + //////////////////////////////////////////////////////////////////// + QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON); + QFieldMetaData field = table.getField("lastName"); + field.withSupplementalMetaData(new ApiFieldMetaDataContainer() + .withApiFieldMetaData(TestUtils.API_NAME, new ApiFieldMetaData() + .withInitialVersion(TestUtils.V2022_Q4) + .withCustomValueMapper(new QCodeReference(PersonLastNameBulkApiValueCustomizer.class)))); + + //////////////////////////////////////////////// + // get a person - make sure custom method ran // + //////////////////////////////////////////////// + Map person = ApiImplementation.get(apiInstanceMetaData, TestUtils.CURRENT_API_VERSION, "person", "1"); + assertEquals("value from prepareToProduceApiValues", person.get("lastName")); + assertEquals(1, PersonLastNameBulkApiValueCustomizer.prepareWasCalledWithThisNoOfRecords); + + ///////////////////////////////////////////////////// + // query for persons - make sure custom method ran // + ///////////////////////////////////////////////////// + Map queryResult = ApiImplementation.query(apiInstanceMetaData, TestUtils.CURRENT_API_VERSION, "person", Collections.emptyMap()); + assertEquals("value from prepareToProduceApiValues", ((List>) queryResult.get("records")).get(0).get("lastName")); + assertEquals(queryResult.get("count"), PersonLastNameBulkApiValueCustomizer.prepareWasCalledWithThisNoOfRecords); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -264,4 +303,39 @@ class ApiImplementationTest extends BaseTest } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static class PersonLastNameBulkApiValueCustomizer extends ApiFieldCustomValueMapper implements ApiFieldCustomValueMapperBulkSupportInterface + { + static Integer prepareWasCalledWithThisNoOfRecords = null; + + private String valueToPutInRecords = null; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public Serializable produceApiValue(QRecord record, String apiFieldName) + { + return (valueToPutInRecords); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void prepareToProduceApiValues(List records) + { + prepareWasCalledWithThisNoOfRecords = records.size(); + valueToPutInRecords = "value from prepareToProduceApiValues"; + } + } + } \ No newline at end of file From 7ab2f332e9dd2ac5892272ce736255f87d57730f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 14 Jun 2024 08:57:24 -0500 Subject: [PATCH 2/4] CE-1113 Add Map of HelpContent at instance and table levels --- .../core/actions/metadata/MetaDataAction.java | 2 + .../QInstanceHelpContentManager.java | 59 ++++++- .../actions/metadata/MetaDataOutput.java | 27 +++- .../core/model/metadata/QInstance.java | 77 ++++++++- .../frontend/QFrontendTableMetaData.java | 17 +- .../model/metadata/tables/QTableMetaData.java | 75 +++++++++ .../QInstanceHelpContentManagerTest.java | 147 ++++++++++++++++++ 7 files changed, 395 insertions(+), 9 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/metadata/MetaDataAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/metadata/MetaDataAction.java index 403d94e3..d8bc012e 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/metadata/MetaDataAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/metadata/MetaDataAction.java @@ -218,6 +218,8 @@ public class MetaDataAction metaDataOutput.setEnvironmentValues(metaDataInput.getInstance().getEnvironmentValues()); + metaDataOutput.setHelpContents(metaDataInput.getInstance().getHelpContent()); + // todo post-customization - can do whatever w/ the result if you want? return metaDataOutput; diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceHelpContentManager.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceHelpContentManager.java index ed5d69da..02376ff3 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceHelpContentManager.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceHelpContentManager.java @@ -105,14 +105,21 @@ public class QInstanceHelpContentManager for(String part : key.split(";")) { String[] parts = part.split(":"); - nameValuePairs.put(parts[0], parts[1]); + if(parts.length > 1) + { + nameValuePairs.put(parts[0], parts[1]); + } + else + { + LOG.info("Discarding help content with key that does not contain name:value format", logPair("key", key), logPair("id", record.getValue("id"))); + } } String tableName = nameValuePairs.get("table"); String processName = nameValuePairs.get("process"); String fieldName = nameValuePairs.get("field"); String sectionName = nameValuePairs.get("section"); - String stepName = nameValuePairs.get("step"); + String stepName = nameValuePairs.get("step"); String widgetName = nameValuePairs.get("widget"); String slotName = nameValuePairs.get("slot"); @@ -143,7 +150,7 @@ public class QInstanceHelpContentManager /////////////////////////////////////////////////////////////////////////////////// if(StringUtils.hasContent(tableName)) { - processHelpContentForTable(key, tableName, sectionName, fieldName, roles, helpContent); + processHelpContentForTable(key, tableName, sectionName, fieldName, slotName, roles, helpContent); } else if(StringUtils.hasContent(processName)) { @@ -153,6 +160,10 @@ public class QInstanceHelpContentManager { processHelpContentForWidget(key, widgetName, slotName, roles, helpContent); } + else if(nameValuePairs.containsKey("instanceLevel")) + { + processHelpContentForInstance(key, slotName, roles, helpContent); + } } catch(Exception e) { @@ -165,7 +176,7 @@ public class QInstanceHelpContentManager /******************************************************************************* ** *******************************************************************************/ - private static void processHelpContentForTable(String key, String tableName, String sectionName, String fieldName, Set roles, QHelpContent helpContent) + private static void processHelpContentForTable(String key, String tableName, String sectionName, String fieldName, String slotName, Set roles, QHelpContent helpContent) { QTableMetaData table = QContext.getQInstance().getTable(tableName); if(table == null) @@ -212,7 +223,21 @@ public class QInstanceHelpContentManager } else { - LOG.info("Unrecognized key format for table help content", logPair("key", key)); + if(!StringUtils.hasContent(slotName)) + { + LOG.info("Missing slot name in table-level help content", logPair("key", key)); + } + else + { + if(helpContent != null) + { + table.withHelpContent(slotName, helpContent); + } + else + { + table.removeHelpContent(slotName, roles); + } + } } } @@ -307,6 +332,30 @@ public class QInstanceHelpContentManager + /******************************************************************************* + ** + *******************************************************************************/ + private static void processHelpContentForInstance(String key, String slotName, Set roles, QHelpContent helpContent) + { + if(!StringUtils.hasContent(slotName)) + { + LOG.info("Missing slot name in instance-level help content", logPair("key", key)); + } + else + { + if(helpContent != null) + { + QContext.getQInstance().withHelpContent(slotName, helpContent); + } + else + { + QContext.getQInstance().removeHelpContent(slotName, roles); + } + } + } + + + /******************************************************************************* ** add a help content object to a list - replacing an entry in the list with the ** same roles if one is found. diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/metadata/MetaDataOutput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/metadata/MetaDataOutput.java index 40174412..717a0acf 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/metadata/MetaDataOutput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/metadata/MetaDataOutput.java @@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendProcessMe import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendReportMetaData; import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; /******************************************************************************* @@ -47,8 +48,9 @@ public class MetaDataOutput extends AbstractActionOutput private Map widgets; private Map environmentValues; - private List appTree; - private QBrandingMetaData branding; + private List appTree; + private QBrandingMetaData branding; + private Map> helpContents; @@ -226,4 +228,25 @@ public class MetaDataOutput extends AbstractActionOutput this.environmentValues = environmentValues; } + + + /******************************************************************************* + ** Setter for helpContents + ** + *******************************************************************************/ + public void setHelpContents(Map> helpContents) + { + this.helpContents = helpContents; + } + + + + /******************************************************************************* + ** Getter for helpContents + ** + *******************************************************************************/ + public Map> getHelpContents() + { + return helpContents; + } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java index 1c5ecbe6..3efc1cda 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java @@ -33,6 +33,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.kingsrook.qqq.backend.core.actions.metadata.JoinGraph; import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.QInstanceHelpContentManager; import com.kingsrook.qqq.backend.core.instances.QInstanceValidationKey; import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput; @@ -44,6 +45,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData; import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface; import com.kingsrook.qqq.backend.core.model.metadata.frontend.AppTreeNode; import com.kingsrook.qqq.backend.core.model.metadata.frontend.AppTreeNodeType; +import com.kingsrook.qqq.backend.core.model.metadata.help.HelpRole; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData; @@ -79,7 +82,7 @@ public class QInstance private QAuthenticationMetaData authentication = null; private QBrandingMetaData branding = null; private Map automationProviders = new HashMap<>(); - private Map messagingProviders = new HashMap<>(); + private Map messagingProviders = new HashMap<>(); //////////////////////////////////////////////////////////////////////////////////////////// // Important to use LinkedHashmap here, to preserve the order in which entries are added. // @@ -100,6 +103,8 @@ public class QInstance private Map supplementalMetaData = new LinkedHashMap<>(); + protected Map> helpContent; + private String deploymentMode; private Map environmentValues = new LinkedHashMap<>(); private String defaultTimeZoneId = "UTC"; @@ -1380,4 +1385,74 @@ public class QInstance this.schedulableTypes = schedulableTypes; } + + + /******************************************************************************* + ** Getter for helpContent + *******************************************************************************/ + public Map> getHelpContent() + { + return (this.helpContent); + } + + + + /******************************************************************************* + ** Setter for helpContent + *******************************************************************************/ + public void setHelpContent(Map> helpContent) + { + this.helpContent = helpContent; + } + + + + /******************************************************************************* + ** Fluent setter for helpContent + *******************************************************************************/ + public QInstance withHelpContent(Map> helpContent) + { + this.helpContent = helpContent; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for adding 1 helpContent (for a slot) + *******************************************************************************/ + public QInstance withHelpContent(String slot, QHelpContent helpContent) + { + if(this.helpContent == null) + { + this.helpContent = new HashMap<>(); + } + + List listForSlot = this.helpContent.computeIfAbsent(slot, (k) -> new ArrayList<>()); + QInstanceHelpContentManager.putHelpContentInList(helpContent, listForSlot); + + return (this); + } + + + + /******************************************************************************* + ** remove a helpContent for a slot based on its set of roles + *******************************************************************************/ + public void removeHelpContent(String slot, Set roles) + { + if(this.helpContent == null) + { + return; + } + + List listForSlot = this.helpContent.get(slot); + if(listForSlot == null) + { + return; + } + + QInstanceHelpContentManager.removeHelpContentByRoleSetFromList(roles, listForSlot); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/frontend/QFrontendTableMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/frontend/QFrontendTableMetaData.java index ef93e024..2f93c109 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/frontend/QFrontendTableMetaData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/frontend/QFrontendTableMetaData.java @@ -39,6 +39,7 @@ import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; 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.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin; @@ -76,7 +77,8 @@ public class QFrontendTableMetaData private boolean usesVariants; private String variantTableLabel; - private ShareableTableMetaData shareableTableMetaData; + private ShareableTableMetaData shareableTableMetaData; + private Map> helpContents; ////////////////////////////////////////////////////////////////////////////////// // do not add setters. take values from the source-object in the constructor!! // @@ -172,6 +174,8 @@ public class QFrontendTableMetaData usesVariants = true; variantTableLabel = actionInput.getInstance().getTable(backend.getVariantOptionsTableName()).getLabel(); } + + this.helpContents = tableMetaData.getHelpContent(); } @@ -382,4 +386,15 @@ public class QFrontendTableMetaData { return shareableTableMetaData; } + + + + /******************************************************************************* + ** Getter for helpContents + ** + *******************************************************************************/ + public Map> getHelpContents() + { + return helpContents; + } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java index 9819f5b3..f2933188 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java @@ -34,6 +34,7 @@ import java.util.Optional; import java.util.Set; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.QInstanceHelpContentManager; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; import com.kingsrook.qqq.backend.core.model.data.QRecordEntityField; @@ -43,6 +44,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.TopLevelMetaDataInterface; import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.help.HelpRole; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; @@ -110,6 +113,8 @@ public class QTableMetaData implements QAppChildMetaData, Serializable, MetaData private ShareableTableMetaData shareableTableMetaData; + protected Map> helpContent; + /******************************************************************************* @@ -1446,4 +1451,74 @@ public class QTableMetaData implements QAppChildMetaData, Serializable, MetaData return (this); } + + + /******************************************************************************* + ** Getter for helpContent + *******************************************************************************/ + public Map> getHelpContent() + { + return (this.helpContent); + } + + + + /******************************************************************************* + ** Setter for helpContent + *******************************************************************************/ + public void setHelpContent(Map> helpContent) + { + this.helpContent = helpContent; + } + + + + /******************************************************************************* + ** Fluent setter for helpContent + *******************************************************************************/ + public QTableMetaData withHelpContent(Map> helpContent) + { + this.helpContent = helpContent; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for adding 1 helpContent (for a slot) + *******************************************************************************/ + public QTableMetaData withHelpContent(String slot, QHelpContent helpContent) + { + if(this.helpContent == null) + { + this.helpContent = new HashMap<>(); + } + + List listForSlot = this.helpContent.computeIfAbsent(slot, (k) -> new ArrayList<>()); + QInstanceHelpContentManager.putHelpContentInList(helpContent, listForSlot); + + return (this); + } + + + + /******************************************************************************* + ** remove a helpContent for a slot based on its set of roles + *******************************************************************************/ + public void removeHelpContent(String slot, Set roles) + { + if(this.helpContent == null) + { + return; + } + + List listForSlot = this.helpContent.get(slot); + if(listForSlot == null) + { + return; + } + + QInstanceHelpContentManager.removeHelpContentByRoleSetFromList(roles, listForSlot); + } + } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceHelpContentManagerTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceHelpContentManagerTest.java index 3148b508..51af9eaa 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceHelpContentManagerTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceHelpContentManagerTest.java @@ -23,7 +23,9 @@ package com.kingsrook.qqq.backend.core.instances; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Function; import com.kingsrook.qqq.backend.core.BaseTest; import com.kingsrook.qqq.backend.core.actions.dashboard.PersonsByCreateDateBarChart; import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; @@ -31,9 +33,12 @@ import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QCollectingLogger; +import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContent; import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContentMetaDataProvider; import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContentRole; @@ -155,6 +160,108 @@ class QInstanceHelpContentManagerTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testTable() throws QException + { + ///////////////////////////////////// + // get the instance from base test // + ///////////////////////////////////// + QInstance qInstance = QContext.getQInstance(); + new HelpContentMetaDataProvider().defineAll(qInstance, TestUtils.MEMORY_BACKEND_NAME, null); + + //////////////////////////////////////////////////////// + // first, assert there's no help content on the table // + //////////////////////////////////////////////////////// + assertThat(qInstance.getTable(TestUtils.TABLE_NAME_PERSON).getHelpContent()).isNullOrEmpty(); + + ////////////////////////////////////// + // insert a record missing its slot // + ////////////////////////////////////// + new InsertAction().execute(new InsertInput(HelpContent.TABLE_NAME).withRecordEntity(new HelpContent() + .withId(1) + .withKey("table:person") + .withContent("content") + .withRole(HelpContentRole.ALL_SCREENS.getId()))); + + ////////////////////////////////// + // assert still no help content // + ////////////////////////////////// + assertThat(qInstance.getTable(TestUtils.TABLE_NAME_PERSON).getHelpContent()).isNullOrEmpty(); + + ////////////////////////// + // insert a good record // + ////////////////////////// + new InsertAction().execute(new InsertInput(HelpContent.TABLE_NAME).withRecordEntity(new HelpContent() + .withId(1) + .withKey("table:person;slot:someSlot") + .withContent("content") + .withRole(HelpContentRole.ALL_SCREENS.getId()))); + + /////////////////////////////////////////////////////////////////////////////////////////////// + // now - post-insert customizer should have automatically added help content to the instance // + /////////////////////////////////////////////////////////////////////////////////////////////// + Map> helpContent = qInstance.getTable(TestUtils.TABLE_NAME_PERSON).getHelpContent(); + assertEquals(1, helpContent.size()); + assertEquals(1, helpContent.get("someSlot").size()); + assertEquals("content", helpContent.get("someSlot").get(0).getContent()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInstance() throws QException + { + ///////////////////////////////////// + // get the instance from base test // + ///////////////////////////////////// + QInstance qInstance = QContext.getQInstance(); + new HelpContentMetaDataProvider().defineAll(qInstance, TestUtils.MEMORY_BACKEND_NAME, null); + + /////////////////////////////////////////////////////////// + // first, assert there's no help content on the instance // + /////////////////////////////////////////////////////////// + assertThat(qInstance.getHelpContent()).isNullOrEmpty(); + + ////////////////////////////////////// + // insert a record missing its slot // + ////////////////////////////////////// + new InsertAction().execute(new InsertInput(HelpContent.TABLE_NAME).withRecordEntity(new HelpContent() + .withId(1) + .withKey("instanceLevel:true") + .withContent("content") + .withRole(HelpContentRole.ALL_SCREENS.getId()))); + + ////////////////////////////////// + // assert still no help content // + ////////////////////////////////// + assertThat(qInstance.getHelpContent()).isNullOrEmpty(); + + ////////////////////////// + // insert a good record // + ////////////////////////// + new InsertAction().execute(new InsertInput(HelpContent.TABLE_NAME).withRecordEntity(new HelpContent() + .withId(1) + .withKey("instanceLevel:true;slot:someSlot") + .withContent("content") + .withRole(HelpContentRole.ALL_SCREENS.getId()))); + + /////////////////////////////////////////////////////////////////////////////////////////////// + // now - post-insert customizer should have automatically added help content to the instance // + /////////////////////////////////////////////////////////////////////////////////////////////// + Map> helpContent = qInstance.getHelpContent(); + assertEquals(1, helpContent.size()); + assertEquals(1, helpContent.get("someSlot").size()); + assertEquals("content", helpContent.get("someSlot").get(0).getContent()); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -285,6 +392,46 @@ class QInstanceHelpContentManagerTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testMalformedKeys() throws QException + { + QInstance qInstance = QContext.getQInstance(); + new HelpContentMetaDataProvider().defineAll(qInstance, TestUtils.MEMORY_BACKEND_NAME, null); + + QCollectingLogger collectingLogger = QLogger.activateCollectingLoggerForClass(QInstanceHelpContentManager.class); + + Function helpContentCreator = (String key) -> new HelpContent() + .withId(1) + .withKey(key) + .withContent("v1") + .withRole(HelpContentRole.INSERT_SCREEN.getId()).toQRecord(); + + QInstanceHelpContentManager.processHelpContentRecord(qInstance, helpContentCreator.apply("foo;bar:baz")); + assertThat(collectingLogger.getCollectedMessages()).hasSize(1); + assertThat(collectingLogger.getCollectedMessages().get(0).getMessage()).contains("Discarding help content with key that does not contain name:value format"); + collectingLogger.clear(); + + QInstanceHelpContentManager.processHelpContentRecord(qInstance, helpContentCreator.apply(null)); + assertThat(collectingLogger.getCollectedMessages()).hasSize(1); + assertThat(collectingLogger.getCollectedMessages().get(0).getMessage()).contains("Error processing a helpContent record"); + collectingLogger.clear(); + + QInstanceHelpContentManager.processHelpContentRecord(qInstance, helpContentCreator.apply("table:notATable;slot:foo")); + assertThat(collectingLogger.getCollectedMessages()).hasSize(1); + assertThat(collectingLogger.getCollectedMessages().get(0).getMessage()).contains("Unrecognized table in help content"); + collectingLogger.clear(); + + QInstanceHelpContentManager.processHelpContentRecord(qInstance, helpContentCreator.apply("table:" + TestUtils.TABLE_NAME_PERSON)); + assertThat(collectingLogger.getCollectedMessages()).hasSize(1); + assertThat(collectingLogger.getCollectedMessages().get(0).getMessage()).contains("Missing slot name"); + collectingLogger.clear(); + } + + + /******************************************************************************* ** *******************************************************************************/ From ad07f3e1f8df510632a4e8a29a574ff3a0eb7f46 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 14 Jun 2024 08:59:04 -0500 Subject: [PATCH 3/4] CE-1113 Avoid an NPE in doSpecHtml, if branding doesn't have an accent color --- .../java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java index ed767327..96625d79 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java @@ -750,7 +750,7 @@ public class QJavalinApiHandler ///////////////////////////////// html = html.replace("{spec-url}", apiInstanceMetaData.getPath() + version + "/openapi.json"); html = html.replace("{version}", version); - html = html.replace("{primaryColor}", branding == null ? "#FF791A" : branding.getAccentColor()); + html = html.replace("{primaryColor}", (branding == null || branding.getAccentColor() == null) ? "#FF791A" : branding.getAccentColor()); if(branding != null && StringUtils.hasContent(branding.getLogo())) { From 4766d2440d338c13726450ba08f9cff1b1f92681 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 14 Jun 2024 10:02:09 -0500 Subject: [PATCH 4/4] ListBuilder.of instead of List.of, for null-tolerance --- .../java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java index 07cee3a7..9e7e7a1b 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java @@ -53,6 +53,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.ObjectUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.backend.core.utils.collections.ListBuilder; import org.apache.commons.lang.BooleanUtils; import org.json.JSONArray; import org.json.JSONObject; @@ -73,7 +74,7 @@ public class QRecordApiAdapter *******************************************************************************/ public static Map qRecordToApiMap(QRecord record, String tableName, String apiName, String apiVersion) throws QException { - return qRecordsToApiMapList(List.of(record), tableName, apiName, apiVersion).get(0); + return qRecordsToApiMapList(ListBuilder.of(record), tableName, apiName, apiVersion).get(0); }