From fce13986b43a4e4b375dbef98444d98ccd3287b4 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 23 Sep 2025 10:52:05 -0500 Subject: [PATCH 1/3] feat(storeRevision): more error checking and update format of exception message if there are errors - put newlines between sub-errors (UI will display as multiple lines) - put Step # in front of all per-step error messages - Check for null workflow step type specifically - Check for missing or invalid JSON for steps and links - avoid NPE if null user in session - initial test on this process (at least its validation logic) --- .../StoreNewWorkflowRevisionProcess.java | 65 +++++++++-- .../StoreNewWorkflowRevisionProcessTest.java | 102 ++++++++++++++++++ 2 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcessTest.java diff --git a/src/main/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcess.java b/src/main/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcess.java index 86330b6..9c4c7e9 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcess.java +++ b/src/main/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcess.java @@ -140,6 +140,38 @@ public class StoreNewWorkflowRevisionProcess implements BackendStep, MetaDataPro throw (new QUserFacingException("Workflow not found by id: " + workflowId)); } + String stepsJSON = runBackendStepInput.getValueString("steps"); + if(!StringUtils.hasContent(stepsJSON)) + { + throw (new QUserFacingException("Workflow steps input was not provided.")); + } + + List workflowSteps; + try + { + workflowSteps = JsonUtils.toObject(stepsJSON, new TypeReference<>() {}); + } + catch(Exception e) + { + throw (new QUserFacingException("Error parsing workflow steps json: " + e.getMessage(), e)); + } + + String linksJSON = runBackendStepInput.getValueString("links"); + if(!StringUtils.hasContent(linksJSON)) + { + throw (new QUserFacingException("Workflow links input was not provided.")); + } + + List workflowLinks; + try + { + workflowLinks = JsonUtils.toObject(linksJSON, new TypeReference<>() {}); + } + catch(Exception e) + { + throw (new QUserFacingException("Error parsing workflow links json: " + e.getMessage(), e)); + } + //////////////////////////// // look up next versionNo // //////////////////////////// @@ -166,15 +198,15 @@ public class StoreNewWorkflowRevisionProcess implements BackendStep, MetaDataPro // insert the workflow revision // ////////////////////////////////// String commitMessage = runBackendStepInput.getValueString("commitMessage"); + String userName = ObjectUtils.tryElse(() -> QContext.getQSession().getUser().getFullName(), "Unknown"); WorkflowRevision workflowRevision = new WorkflowRevision(); workflowRevision.setWorkflowId(workflowId); workflowRevision.setVersionNo(versionNo); - workflowRevision.setCommitMessage(StringUtils.hasContent(commitMessage) ? commitMessage : "New workflow revision created by " + QContext.getQSession().getUser().getFullName()); + workflowRevision.setCommitMessage(StringUtils.hasContent(commitMessage) ? commitMessage : "New workflow revision created by " + userName); workflowRevision.setApiName(runBackendStepInput.getValueString("apiName")); workflowRevision.setApiVersion(runBackendStepInput.getValueString("apiVersion")); - workflowRevision.setAuthor(ObjectUtils.tryElse(() -> QContext.getQSession().getUser().getFullName(), "Unknown")); + workflowRevision.setAuthor(userName); - List workflowSteps = JsonUtils.toObject(runBackendStepInput.getValueString("steps"), new TypeReference<>() {}); if(!workflowSteps.isEmpty()) { workflowRevision.setStartStepNo(workflowSteps.get(0).getStepNo()); @@ -208,19 +240,24 @@ public class StoreNewWorkflowRevisionProcess implements BackendStep, MetaDataPro ////////////////////////////////////////////////////////////////////////////////////////////// // store steps (first setting revision id, validating, and putting a fresh summary on each) // ////////////////////////////////////////////////////////////////////////////////////////////// - List errors = new ArrayList<>(); + List errors = new ArrayList<>(); + List errorsForThisStep = new ArrayList<>(); workflowSteps.forEach(step -> { try { step.setWorkflowRevisionId(insertedRevisionId); - WorkflowStepType workflowStepType = WorkflowsRegistry.of(QContext.getQInstance()).getWorkflowStepType(step.getWorkflowStepTypeName()); - Map inputValues = ValueUtils.getValueAsMap(step.getInputValuesJson()); + WorkflowStepType workflowStepType = WorkflowsRegistry.of(QContext.getQInstance()).getWorkflowStepType(step.getWorkflowStepTypeName()); + if(workflowStepType == null) + { + throw (new QUserFacingException("Unknown workflow step type " + step.getWorkflowStepTypeName())); + } //////////////////////////////////// // summary - if it fails, :shrug: // //////////////////////////////////// + Map inputValues = ValueUtils.getValueAsMap(step.getInputValuesJson()); try { step.setSummary(workflowStepType.getDynamicStepSummary(workflowId, inputValues)); @@ -237,18 +274,27 @@ public class StoreNewWorkflowRevisionProcess implements BackendStep, MetaDataPro if(validatorCodeReference != null) { WorkflowStepValidatorInterface validator = QCodeLoader.getAdHoc(WorkflowStepValidatorInterface.class, validatorCodeReference); - validator.validate(step, inputValues, workflowRevisionRecord, workflowRecord, errors); + validator.validate(step, inputValues, workflowRevisionRecord, workflowRecord, errorsForThisStep); + if(!errorsForThisStep.isEmpty()) + { + /////////////////////////////////////// + // put Step # in front of all errors // + /////////////////////////////////////// + errors.addAll(errorsForThisStep.stream().map(e -> "Step " + step.getStepNo() + ": " + e).toList()); + errorsForThisStep.clear(); + } } } catch(Exception e) { - errors.add("Error processing step [" + step.getSummary() + "]: " + e.getMessage()); + errors.add("Error processing Step " + step.getStepNo() + ": " + e.getMessage()); } }); if(!errors.isEmpty()) { - throw (new QUserFacingException("Validation errors in steps: " + StringUtils.joinWithCommasAndAnd(errors))); + String message = errors.size() + " validation error" + StringUtils.plural(errors) + " occurred within the workflow's steps:\n" + StringUtils.join("\n", errors); + throw (new QUserFacingException(message)); } new InsertAction().execute(new InsertInput(WorkflowStep.TABLE_NAME).withRecordEntities(workflowSteps).withTransaction(transaction)); @@ -256,7 +302,6 @@ public class StoreNewWorkflowRevisionProcess implements BackendStep, MetaDataPro ///////////////// // store links // ///////////////// - List workflowLinks = JsonUtils.toObject(runBackendStepInput.getValueString("links"), new TypeReference<>() {}); workflowLinks.forEach(link -> link.setWorkflowRevisionId(insertedRevisionId)); new InsertAction().execute(new InsertInput(WorkflowLink.TABLE_NAME).withRecordEntities(workflowLinks).withTransaction(transaction)); diff --git a/src/test/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcessTest.java b/src/test/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcessTest.java new file mode 100644 index 0000000..5c3f587 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/workflows/processes/StoreNewWorkflowRevisionProcessTest.java @@ -0,0 +1,102 @@ +/* + * 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/ + * + * 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.qbits.workflows.processes; + + +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.workflows.BaseTest; +import com.kingsrook.qbits.workflows.TestWorkflowDefinitions; +import com.kingsrook.qbits.workflows.WorkflowsTestDataSource; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + + +/******************************************************************************* + ** Unit test for StoreNewWorkflowRevisionProcess + *******************************************************************************/ +class StoreNewWorkflowRevisionProcessTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException + { + TestWorkflowDefinitions.registerTestWorkflowTypes(); + Integer workflowId = WorkflowsTestDataSource.insertTestWorkflow(); + + RunBackendStepInput input = new RunBackendStepInput(); + + RunBackendStepOutput output = new RunBackendStepOutput(); + assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output)) + .hasMessageContaining("Workflow id is required"); + + input.addValue("workflowId", -999); + assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output)) + .hasMessageContaining("Workflow not found by id: -999"); + + input.addValue("workflowId", workflowId); + assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output)) + .hasMessageContaining("steps input was not provided"); + + input.addValue("steps", "1, 2, 3"); + assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output)) + .hasMessageContaining("Error parsing workflow steps"); + + input.addValue("steps", JsonUtils.toJson(List.of( + WorkflowsTestDataSource.newStep(1, "myStepType", Map.of("foo", 1)), + WorkflowsTestDataSource.newStep(2, "yourStepType", Map.of("bar", true, "baz", false)) + ))); + assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output)) + .hasMessageContaining("links input was not provided"); + + input.addValue("links", "not json"); + assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output)) + .hasMessageContaining("Error parsing workflow links"); + + input.addValue("links", JsonUtils.toJson(List.of(WorkflowsTestDataSource.newLink(1, 2)))); + assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output)) + .hasMessageContaining("2 validation errors occurred within the workflow's steps:") + .hasMessageContaining("Error processing Step 1: Unknown workflow step type myStepType") + .hasMessageContaining("Error processing Step 2: Unknown workflow step type yourStepType"); + + input.addValue("steps", JsonUtils.toJson(List.of( + WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("foo", 1)), + WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.BOOLEAN_CONDITIONAL, Map.of("bar", true, "baz", false)) + ))); + new StoreNewWorkflowRevisionProcess().run(input, output); + assertNotNull(output.getValue("workflowRevisionId")); + assertEquals(2, output.getValue("versionNo")); + + new StoreNewWorkflowRevisionProcess().run(input, output); + assertEquals(3, output.getValue("versionNo")); + } + +} \ No newline at end of file -- 2.54.0 From 6ef1727eb8496139af7f779e4033ea94382f7bce Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 23 Sep 2025 11:11:53 -0500 Subject: [PATCH 2/3] feat(updateRecordFieldStep): Adjust possible value filters that use "${input.myFieldName}" to consider getting that value (via coalescing) from "??${input.workflow.myFieldName}" Add initial tests on this class --- ...pdateInputRecordFieldMetaDataAdjuster.java | 42 ++++++ ...eInputRecordFieldMetaDataAdjusterTest.java | 124 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldMetaDataAdjusterTest.java diff --git a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldMetaDataAdjuster.java b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldMetaDataAdjuster.java index c44a1a9..f2b8782 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldMetaDataAdjuster.java +++ b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldMetaDataAdjuster.java @@ -25,6 +25,7 @@ package com.kingsrook.qbits.workflows.implementations.recordworkflows; import java.io.Serializable; import java.util.Collections; import java.util.List; +import java.util.ListIterator; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -35,6 +36,8 @@ import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAc import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput; import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceOutput; import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference; @@ -143,6 +146,8 @@ public class UpdateInputRecordFieldMetaDataAdjuster implements FormAdjusterInter } } + adjustPossibleValueSourceFilterCriteriaInputs(updatedField.getPossibleValueSourceFilter()); + /////////////////////////////////////////////////////////////////////////////////// // build the output object, starting with the new meta-data of the 'value' field // /////////////////////////////////////////////////////////////////////////////////// @@ -186,6 +191,43 @@ public class UpdateInputRecordFieldMetaDataAdjuster implements FormAdjusterInter + /*************************************************************************** + * if the field has a possible-value-source filter on it, and that filter + * uses an input field e.g., ${input.someField} - consider that this value + * *might* be an attribute on the workflow so add an optional + * ??${input.workflow.someField} after it. + ***************************************************************************/ + static void adjustPossibleValueSourceFilterCriteriaInputs(QQueryFilter filter) + { + if(filter == null) + { + return; + } + + for(QFilterCriteria criteria : CollectionUtils.nonNullList(filter.getCriteria())) + { + List values = CollectionUtils.nonNullList(criteria.getValues()); + ListIterator valueIterator = values.listIterator(); + while(valueIterator.hasNext()) + { + Serializable value = valueIterator.next(); + if(value instanceof String stringValue && stringValue.startsWith("${input.")) + { + String inputFieldName = stringValue.replace("${input.", "").replace("}", ""); + String updatedValue = stringValue + "??${input.workflow." + inputFieldName + "}"; + valueIterator.set(updatedValue); + } + } + } + + for(QQueryFilter subFilter : CollectionUtils.nonNullList(filter.getSubFilters())) + { + adjustPossibleValueSourceFilterCriteriaInputs(subFilter); + } + } + + + /*************************************************************************** ** ***************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldMetaDataAdjusterTest.java b/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldMetaDataAdjusterTest.java new file mode 100644 index 0000000..1c2754d --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldMetaDataAdjusterTest.java @@ -0,0 +1,124 @@ +/* + * 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/ + * + * 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.qbits.workflows.implementations.recordworkflows; + + +import java.io.Serializable; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.workflows.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendFieldMetaData; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.FormAdjusterInput; +import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.FormAdjusterOutput; +import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.RunFormAdjusterProcess; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Unit test for UpdateInputRecordFieldMetaDataAdjuster + *******************************************************************************/ +class UpdateInputRecordFieldMetaDataAdjusterTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testExecute() throws QException + { + Map allValues = Map.of( + "workflowValuesJSON", JsonUtils.toJson(Map.of("tableName", TABLE_NAME_PERSON)), + "workflowRevisionValuesJSON", JsonUtils.toJson(Map.of()), + "value", 1 + ); + + /////////////////////// + // run for on-change // + /////////////////////// + FormAdjusterOutput onChangeOutput = new UpdateInputRecordFieldMetaDataAdjuster().execute(new FormAdjusterInput() + .withFieldName("fieldName") + .withNewValue(TABLE_NAME_PERSON + ".firstName") + .withAllValues(allValues) + .withEvent(RunFormAdjusterProcess.EVENT_ON_CHANGE)); + assertEquals(Set.of("value"), onChangeOutput.getFieldsToClear()); + + assertEquals(1, onChangeOutput.getUpdatedFieldMetaData().size()); + assertThat(onChangeOutput.getUpdatedFieldDisplayValues()).isNullOrEmpty(); + QFrontendFieldMetaData updatedValueField = onChangeOutput.getUpdatedFieldMetaData().get("value"); + assertEquals("Value (First Name)", updatedValueField.getLabel()); + + ///////////////////// + // run for on-load // + ///////////////////// + new InsertAction().execute(new InsertInput(TABLE_NAME_SHAPE).withRecord(new QRecord().withValue("id", 1).withValue("name", "Square"))); + + FormAdjusterOutput onLoadOutput = new UpdateInputRecordFieldMetaDataAdjuster().execute(new FormAdjusterInput() + .withFieldName("fieldName") + .withNewValue(TABLE_NAME_PERSON + ".favoriteShapeId") + .withAllValues(allValues) + .withEvent(RunFormAdjusterProcess.EVENT_ON_LOAD)); + assertThat(onLoadOutput.getFieldsToClear()).isNullOrEmpty(); + assertEquals(Map.of("value", "Square"), onLoadOutput.getUpdatedFieldDisplayValues()); + updatedValueField = onLoadOutput.getUpdatedFieldMetaData().get("value"); + assertEquals("Value (Favorite Shape)", updatedValueField.getLabel()); + assertEquals(TABLE_NAME_SHAPE, updatedValueField.getPossibleValueSourceName()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testAdjustPossibleValueSourceFilterCriteriaInputs() + { + QFieldMetaData field = new QFieldMetaData(); + UpdateInputRecordFieldMetaDataAdjuster.adjustPossibleValueSourceFilterCriteriaInputs(field.getPossibleValueSourceFilter()); + assertNull(field.getPossibleValueSourceFilter()); + + field.setPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, 1))); + UpdateInputRecordFieldMetaDataAdjuster.adjustPossibleValueSourceFilterCriteriaInputs(field.getPossibleValueSourceFilter()); + assertEquals(1, field.getPossibleValueSourceFilter().getCriteria().get(0).getValues().get(0)); + + field.setPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("someId", QCriteriaOperator.EQUALS, "${input.someId}"))); + UpdateInputRecordFieldMetaDataAdjuster.adjustPossibleValueSourceFilterCriteriaInputs(field.getPossibleValueSourceFilter()); + assertEquals("${input.someId}??${input.workflow.someId}", field.getPossibleValueSourceFilter().getCriteria().get(0).getValues().get(0)); + + field.setPossibleValueSourceFilter(new QQueryFilter().withSubFilter(new QQueryFilter(new QFilterCriteria("someId", QCriteriaOperator.EQUALS, "${input.someId}")))); + UpdateInputRecordFieldMetaDataAdjuster.adjustPossibleValueSourceFilterCriteriaInputs(field.getPossibleValueSourceFilter()); + assertEquals("${input.someId}??${input.workflow.someId}", field.getPossibleValueSourceFilter().getSubFilters().get(0).getCriteria().get(0).getValues().get(0)); + + } + +} \ No newline at end of file -- 2.54.0 From 910d5c9858f68e0305a1d93f395f65e1ba31ec4f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 23 Sep 2025 11:13:18 -0500 Subject: [PATCH 3/3] fix(updateRecordFieldStep): Fix cases where possible values weren't being translated in step summary/log (when "tableName." prefix was present). Also factor some static util method out, for use in application-defined steps of a similar nature. --- .../UpdateInputRecordFieldStep.java | 117 +++++++++---- .../UpdateInputRecordFieldStepTest.java | 161 ++++++++++++++++++ 2 files changed, 249 insertions(+), 29 deletions(-) create mode 100644 src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldStepTest.java diff --git a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldStep.java b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldStep.java index 9fbed3e..090711f 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldStep.java +++ b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldStep.java @@ -60,7 +60,10 @@ import org.json.JSONObject; /******************************************************************************* - ** workflow step that updates a record with one value in one field + * Workflow step that updates a record with one value in one field + * + * This class includes some public static methods that can be useful for other + * workflow step implementations that have similar functionality. *******************************************************************************/ public class UpdateInputRecordFieldStep extends WorkflowStepType implements WorkflowStepExecutorInterface, WorkflowStepValidatorInterface { @@ -101,7 +104,15 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work /*************************************************************************** - ** + * For a given fieldName, tableName, and api and version (which come from a + * workflowRevision), get QFieldMetaData for that field (if it's in that + * API Version). + * + * @param fieldNameMaybeWithTableNamePrefix - field name - which can be + * prefixed by tableName + "." + * @param tableName table name + * @param workflowRevision from which api name and version will come + * @return optional containing QFieldMetaData if found, or empty if not. ***************************************************************************/ public static Optional getApiField(String fieldNameMaybeWithTableNamePrefix, String tableName, WorkflowRevision workflowRevision) throws QException { @@ -121,6 +132,31 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work ***************************************************************************/ @Override public String getDynamicStepSummary(Integer workflowId, Map inputValues) throws QException + { + QRecord workflowRecord = GetAction.execute(Workflow.TABLE_NAME, workflowId); + String tableName = workflowRecord.getValueString("tableName"); + return getStepSummary(inputValues, tableName, false); + } + + + + /*************************************************************************** + * Helper for {@link #getDynamicStepSummary(Integer, Map)} or + * {@link #execute(WorkflowStep, Map, WorkflowExecutionContext)} to generate + * a string like "${fieldLabel} will be / was set to ${value}" or "... cleared out" + * where the value will be a translated possible value if applicable. + * + * @param inputValues map of values user assigned to the step. expected to + * include String fieldName and String value. + * @param tableName table that the field should come from. Many times this + * may be the tableName on the workflow - but it could be + * a hard-coded tableName in other implementations of this + * step type. + * @param isPastTense to format the message as "will be" or "was". e.g., + * for getDynamicStepSummary would be false ("will be") + * and for execute would be true ("was"). + ***************************************************************************/ + public static String getStepSummary(Map inputValues, String tableName, boolean isPastTense) { String fieldName = ValueUtils.getValueAsString(inputValues.get("fieldName")); String value = ValueUtils.getValueAsString(inputValues.get("value")); @@ -137,45 +173,38 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work } } - return getStepSummary(workflowId, fieldName, apiName, apiVersion, value, false); + return getStepSummary(fieldName, tableName, apiName, apiVersion, value, isPastTense); } /*************************************************************************** - ** + * ***************************************************************************/ - private String getStepSummary(Integer workflowId, String fieldName, String apiName, String apiVersion, String value, boolean isPastTense) + private static String getStepSummary(String fieldName, String tableName, String apiName, String apiVersion, String value, boolean isPastTense) { String fieldLabel = null; - QTableMetaData table = null; + QTableMetaData table = QContext.getQInstance().getTable(tableName); if(StringUtils.hasContent(fieldName)) { try { - if(workflowId != null) + WorkflowRevision workflowRevision = new WorkflowRevision().withApiName(apiName).withApiVersion(apiVersion); + if(WorkflowStepUtils.useApi(workflowRevision)) { - QRecord workflowRecord = GetAction.execute(Workflow.TABLE_NAME, workflowId); - table = QContext.getQInstance().getTable(workflowRecord.getValueString("tableName")); - - WorkflowRevision workflowRevision = new WorkflowRevision().withApiName(apiName).withApiVersion(apiVersion); - if(WorkflowStepUtils.useApi(workflowRevision)) + Optional optionalApiField = getApiField(fieldName, tableName, workflowRevision); + if(optionalApiField.isPresent()) { - Optional optionalApiField = getApiField(fieldName, table.getName(), workflowRevision); - if(optionalApiField.isPresent()) - { - // fieldName = optionalApiField.field().getName(); // hmm? - fieldLabel = optionalApiField.get().getLabel(); - } - } - else - { - FieldAndJoinTable fieldAndJoinTable = FieldAndJoinTable.get(table, fieldName); - fieldName = fieldAndJoinTable.field().getName(); - fieldLabel = fieldAndJoinTable.field().getLabel(); + fieldLabel = optionalApiField.get().getLabel(); } } + else + { + FieldAndJoinTable fieldAndJoinTable = FieldAndJoinTable.get(table, fieldName); + fieldName = fieldAndJoinTable.field().getName(); + fieldLabel = fieldAndJoinTable.field().getLabel(); + } } catch(Exception e) { @@ -183,15 +212,22 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work } } - String verb = isPastTense ? "was" : "will be"; - if(StringUtils.hasContent(fieldLabel)) { + String verb = isPastTense ? "was" : "will be"; if(StringUtils.hasContent(value)) { String displayValue = value; try { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // field names from this PVS are always table.field - but QValueFormatter doesn't expect that - so strip away table. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(fieldName != null && fieldName.startsWith(tableName + ".")) + { + fieldName = fieldName.replace(tableName + ".", ""); + } + QRecord record = new QRecord().withValue(fieldName, value); QValueFormatter.setDisplayValuesInRecordsIncludingPossibleValueTranslations(table, List.of(record)); displayValue = record.getDisplayValue(fieldName); @@ -241,7 +277,8 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work context.doesRecordNeedUpdated.set(true); - String stepSummary = getStepSummary(context.getWorkflow().getId(), fieldName, context.getWorkflowRevision().getApiName(), context.getWorkflowRevision().getApiVersion(), value, true); + String tableName = context.getWorkflow().getTableName(); + String stepSummary = getStepSummary(fieldName, tableName, context.getWorkflowRevision().getApiName(), context.getWorkflowRevision().getApiVersion(), value, true); return new WorkflowStepOutput(value, stepSummary); } @@ -284,12 +321,34 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work @Override public void validate(WorkflowStep step, Map inputValues, QRecord workflowRevision, QRecord workflow, List errors) throws QException { - if(WorkflowStepUtils.useApi(new WorkflowRevision(workflowRevision))) + validate(workflow.getValueString("tableName"), inputValues, workflowRevision, errors); + } + + + + /*************************************************************************** + * implementation logic for {@link #validate(WorkflowStep, Map, QRecord, QRecord, List)}. + * makes sure that, if the workflow uses API, that the field is a valid field + * for the api version being used in the workflow revision. + * + * @param tableName table that the field should come from. Many times this + * may be the tableName on the workflow - but it could be + * a hard-coded tableName in other implementations of this + * step type. + * @param inputValues map of values user assigned to the step. expected to + * include String fieldName. + * @param workflowRevision from which api name and version will come + * @param errors Out param - list of error message strings, which will be + * added to if the input fieldName isn't in the table for the + * api version on the workflowRevision + ***************************************************************************/ + public static void validate(String tableName, Map inputValues, QRecord workflowRevision, List errors) throws QException + { + if(workflowRevision != null && WorkflowStepUtils.useApi(new WorkflowRevision(workflowRevision))) { String fieldName = ValueUtils.getValueAsString(inputValues.get("fieldName")); String apiName = ValueUtils.getValueAsString(workflowRevision.getValueString("apiName")); String apiVersion = ValueUtils.getValueAsString(workflowRevision.getValueString("apiVersion")); - String tableName = ValueUtils.getValueAsString(workflow.getValueString("tableName")); Optional optionalApiField = getApiField(fieldName, tableName, new WorkflowRevision(workflowRevision)); if(optionalApiField.isEmpty()) diff --git a/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldStepTest.java b/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldStepTest.java new file mode 100644 index 0000000..703c440 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/UpdateInputRecordFieldStepTest.java @@ -0,0 +1,161 @@ +/* + * 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/ + * + * 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.qbits.workflows.implementations.recordworkflows; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.workflows.BaseTest; +import com.kingsrook.qbits.workflows.model.WorkflowRevision; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Unit test for UpdateInputRecordFieldStep + *******************************************************************************/ +class UpdateInputRecordFieldStepTest extends BaseTest +{ + private static WorkflowRevision workflowRevisionV1 = new WorkflowRevision().withApiName(API_NAME).withApiVersion(V1); + private static WorkflowRevision workflowRevisionV2 = new WorkflowRevision().withApiName(API_NAME).withApiVersion(V2); + private static WorkflowRevision workflowRevisionV3 = new WorkflowRevision().withApiName(API_NAME).withApiVersion(V3); + private static WorkflowRevision workflowRevisionWithoutApi = new WorkflowRevision(); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testGetApiField() throws QException + { + assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".firstName", TABLE_NAME_PERSON, workflowRevisionV1)) + .isPresent().get().hasFieldOrPropertyWithValue("name", "firstName"); + + assertThat(UpdateInputRecordFieldStep.getApiField("firstName", TABLE_NAME_PERSON, workflowRevisionV1)) + .isPresent().get().hasFieldOrPropertyWithValue("name", "firstName"); + + assertThat(UpdateInputRecordFieldStep.getApiField("noSuchField", TABLE_NAME_PERSON, workflowRevisionV1)) + .isEmpty(); + + assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".noSuchField", TABLE_NAME_PERSON, workflowRevisionV1)) + .isEmpty(); + + ///////////////////////////////////// + // this field is excluded from api // + ///////////////////////////////////// + assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".debt", TABLE_NAME_PERSON, workflowRevisionV1)) + .isEmpty(); + + /////////////////////////////// + // this field is added in V3 // + /////////////////////////////// + assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".salary", TABLE_NAME_PERSON, workflowRevisionV1)) + .isEmpty(); + + assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".salary", TABLE_NAME_PERSON, workflowRevisionV3)) + .isPresent().get().hasFieldOrPropertyWithValue("name", "salary"); + + ////////////////////////////////////////// + // confirm shoeCount/noOfShoes renaming // + ////////////////////////////////////////// + assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".shoeCount", TABLE_NAME_PERSON, workflowRevisionV1)) + .isPresent().get().hasFieldOrPropertyWithValue("name", "shoeCount"); + + assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".shoeCount", TABLE_NAME_PERSON, workflowRevisionV2)) + .isEmpty(); + + assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".noOfShoes", TABLE_NAME_PERSON, workflowRevisionV1)) + .isEmpty(); + + assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".noOfShoes", TABLE_NAME_PERSON, workflowRevisionV2)) + .isPresent().get().hasFieldOrPropertyWithValue("name", "noOfShoes"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testGetStepSummary() throws QException + { + assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".firstName", "value", "John"), TABLE_NAME_PERSON, false)) + .isEqualTo("First Name will be set to 'John'"); + + assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".firstName", "value", "John"), TABLE_NAME_PERSON, true)) + .isEqualTo("First Name was set to 'John'"); + + assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".firstName", "value", ""), TABLE_NAME_PERSON, false)) + .isEqualTo("First Name will be cleared out"); + + assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".firstName"), TABLE_NAME_PERSON, true)) + .isEqualTo("First Name was cleared out"); + + new InsertAction().execute(new InsertInput(TABLE_NAME_SHAPE).withRecord(new QRecord().withValue("id", 1).withValue("name", "Square"))); + + assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".favoriteShapeId", "value", "1"), TABLE_NAME_PERSON, false)) + .isEqualTo("Favorite Shape will be set to 'Square'"); + + assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".favoriteShapeId", "value", "1"), TABLE_NAME_PERSON, true)) + .isEqualTo("Favorite Shape was set to 'Square'"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testValidate() throws QException + { + assertThat(runValidate(Map.of("fieldName", TABLE_NAME_PERSON + ".firstName"), workflowRevisionV1)).isEmpty(); + + //////////////////////////////////////////////////////////////////////////////////// + // debt field - found if not using api, else not found as it is excluded from api // + //////////////////////////////////////////////////////////////////////////////////// + assertThat(runValidate(Map.of("fieldName", TABLE_NAME_PERSON + ".debt"), workflowRevisionWithoutApi)).isEmpty(); + assertThat(runValidate(Map.of("fieldName", TABLE_NAME_PERSON + ".debt"), workflowRevisionV1)).hasSize(1) + .element(0).asString() + .contains("Could not find field 'person.debt' in table '" + TABLE_NAME_PERSON + "' for API '" + API_NAME + "' version 'v1'"); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + private List runValidate(Map inputValues, WorkflowRevision workflowRevision) throws QException + { + List errors = new ArrayList<>(); + UpdateInputRecordFieldStep.validate(TABLE_NAME_PERSON, inputValues, workflowRevision == null ? null : workflowRevision.toQRecord(), errors); + return (errors); + } + +} + -- 2.54.0