diff --git a/.circleci/config.yml b/.circleci/config.yml
index 1045e671..fccbcbc3 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -98,6 +98,31 @@ commands:
- ~/.m2
key: v1-dependencies-{{ checksum "pom.xml" }}
+ install_asciidoctor:
+ steps:
+ - checkout
+ - run:
+ name: Install asciidoctor
+ command: |
+ sudo apt-get update
+ sudo apt install -y asciidoctor
+
+ run_asciidoctor:
+ steps:
+ - run:
+ name: Run asciidoctor
+ command: |
+ cd docs
+ asciidoctor -a docinfo=shared index.adoc
+
+ upload_docs_site:
+ steps:
+ - run:
+ name: scp html to justinsgotskinnylegs.com
+ command: |
+ cd docs
+ scp index.html dkelkhoff@45.79.44.221:/mnt/first-volume/dkelkhoff/nginx/html/justinsgotskinnylegs.com/qqq-docs.html
+
jobs:
mvn_test:
executor: localstack/default
@@ -114,6 +139,13 @@ jobs:
- mvn_verify
- mvn_jar_deploy
+ publish_asciidoc:
+ executor: localstack/default
+ steps:
+ - install_asciidoctor
+ - run_asciidoctor
+ - upload_docs_site
+
workflows:
test_only:
jobs:
@@ -135,3 +167,9 @@ workflows:
tags:
only: /(version|snapshot)-.*/
+ publish-docs:
+ jobs:
+ - publish_asciidoc:
+ filters:
+ branches:
+ only: /dev/
diff --git a/docs/metaData/Fields.adoc b/docs/metaData/Fields.adoc
index 93d11dcd..eb20fa84 100644
--- a/docs/metaData/Fields.adoc
+++ b/docs/metaData/Fields.adoc
@@ -21,5 +21,99 @@ Used to set values in the `displayValues` map within a `QRecord`.
* `possibleValueSourceName` - *String* - Reference to a {link-pvs} to be used for this field.
Values in this field should correspond to ids from the referenced Possible Value Source.
* `maxLength` - *Integer* - Maximum length (number of characters) allowed for values in this field.
-Only applicable for fields with `type=STRING`.
-* `
\ No newline at end of file
+Only applicable for fields with `type=STRING`. Needs to be used with a `FieldBehavior` of type `ValueTooLongBehavior`.
+
+==== Field Behaviors
+Additional behaviors can be attached to fields through the use of the `behaviors` attribute,
+which is a `Set` of 0 or more instances of implementations of the `FieldBehavior` interface.
+Note that in some cases, these instances may be `enum` constants,
+but other times may be regular Objects.
+
+QQQ provides a set of common field behaviors.
+Applications can also define their own field behaviors by implementing the `FieldBehavior` interface,
+and attaching instances of their custom behavior classes to fields.
+
+===== ValueTooLongBehavior
+Used on String fields. Requires the field to have a `maxLength` set.
+Depending on the chosen instance of this enum, before a record is Inserted or Updated,
+if the value in the field is longer than the `maxLength`, then one of the following actions can occur:
+
+* `TRUNCATE` - The value will be simply truncated to the `maxLength`.
+* `TRUNCATE_ELLIPSIS` - The value will be truncated to 3 characters less than the `maxLength`, and three periods (an ellipsis) will be placed at the end.
+* `ERROR` - An error will be reported, and the record will not be inserted or updated.
+* `PASS_THROUGH` - Nothing will happen. This is the same as not having a `ValueTooLongBehavior` on the field.
+
+[source,java]
+.Examples of using ValueTooLongBehavior
+----
+ new QFieldMetaData("sku", QFieldType.STRING)
+ .withMaxLength(40),
+ .withBehavior(ValueTooLongBehavior.ERROR),
+
+ new QFieldMetaData("reason", QFieldType.STRING)
+ .withMaxLength(250),
+ .withBehavior(ValueTooLongBehavior.TRUNCATE_ELLIPSIS),
+
+----
+
+===== DynamicDefaultValueBehavior
+Used to set a dynamic default value to a field when it is being inserted or updated.
+For example, instead of having a hard-coded `defaultValue` specified in the field meta-data,
+and instead of having to add, for example, a pre-insert custom action.
+
+* `CREATE_DATE` - On inserts, sets the field's value to the current time.
+* `MODIFY_DATE` - On inserts and updates, sets the field's value to the current time.
+* `USER_ID` - On inserts and updates, sets the field's value to the current user's id (but only if the value is currently null).
+
+_Note that the `QInstanceEnricher` will, by default, add the `CREATE_DATE` and `MODIFY_DATE` `DynamicDefaultValueBehavior`
+options to any fields named `"createDate"` or `"modifyDate"`.
+This behavior can be disabled by setting the `configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate` property
+on the `QInstanceEnricher` instance used by the application to `false`._
+
+[source,java]
+.Examples of using DynamicDefaultValueBehavior
+----
+ new QFieldMetaData("createDate", QFieldType.DATE_TIME)
+ .withBehavior(DynamicDefaultValueBehavior.CREATE_DATE),
+
+ new QFieldMetaData("modifyDate", QFieldType.DATE_TIME)
+ .withBehavior(DynamicDefaultValueBehavior.MODIFY_DATE),
+
+ new QFieldMetaData("createdByUserId", QFieldType.STRING)
+ .withBehavior(DynamicDefaultValueBehavior.USER_ID),
+----
+
+===== DateTimeDisplayValueBehavior
+By default, in QQQ, fields of type `DATE_TIME` are stored in UTC,
+and their values in a QRecord is a java `Instant` instance, which is always UTC.
+However, frontends will prefer to display date-time values in the user's local Time Zone whenever possible.
+
+Using `DateTimeDisplayValueBehavior` allows a `DATE_TIME` field to be displayed in a different Time Zone.
+An example use-case for this would be displaying airplane flight times,
+where you would want a flight from California to New York to display Pacific Time for its departure time,
+and Eastern Time for its arrival.
+
+An instance of `DateTimeDisplayValueBehavior` can be configured to either use a hard-coded time `ZoneId`
+(for example, to always show users UTC, or a business's home-office time zone).
+Or, it can be set up to get the time zone to use from another field in the table.
+
+[source,java]
+.Examples of using DateTimeDisplayValueBehavior
+----
+new QTableMetaData().withName("flights").withFields(List.of(
+ ...
+ new QFieldMetaData("departureTimeZoneId", QFieldType.STRING),
+ new QFieldMetaData("arrivaTimeZoneId", QFieldType.STRING),
+
+ new QFieldMetaData("departureTime", QFieldType.DATE_TIME)
+ .withBehavior(new DateTimeDisplayValueBehavior()
+ .withZoneIdFromFieldName("departureTimeZoneId")),
+
+ new QFieldMetaData("arrivalTime", QFieldType.DATE_TIME)
+ .withBehavior(new DateTimeDisplayValueBehavior()
+ .withZoneIdFromFieldName("arrivalTimeZoneId"))
+
+ new QFieldMetaData("ticketSaleStartDateTime", QFieldType.DATE_TIME)
+ .withBehavior(new DateTimeDisplayValueBehavior()
+ .withDefaultZoneId("UTC"))
+----
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/async/AsyncJobStatus.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/async/AsyncJobStatus.java
index 3ec54516..c5cf95f3 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/async/AsyncJobStatus.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/async/AsyncJobStatus.java
@@ -31,6 +31,7 @@ import java.io.Serializable;
*******************************************************************************/
public class AsyncJobStatus implements Serializable
{
+ private String jobName;
private AsyncJobState state;
private String message;
private Integer current;
@@ -187,4 +188,36 @@ public class AsyncJobStatus implements Serializable
{
this.cancelRequested = cancelRequested;
}
+
+
+
+ /*******************************************************************************
+ ** Getter for jobName
+ *******************************************************************************/
+ public String getJobName()
+ {
+ return (this.jobName);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for jobName
+ *******************************************************************************/
+ public void setJobName(String jobName)
+ {
+ this.jobName = jobName;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for jobName
+ *******************************************************************************/
+ public AsyncJobStatus withJobName(String jobName)
+ {
+ this.jobName = jobName;
+ return (this);
+ }
+
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/async/NonPersistedAsyncJobCallback.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/async/NonPersistedAsyncJobCallback.java
new file mode 100644
index 00000000..0e413a38
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/async/NonPersistedAsyncJobCallback.java
@@ -0,0 +1,65 @@
+/*
+ * 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.backend.core.actions.async;
+
+
+import java.util.UUID;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+
+
+/*******************************************************************************
+ ** subclass designed to be used when we want there to be an instance (so code
+ ** doesn't have to all be null-tolerant), but there's no one who will ever be
+ ** reading the status data, so we don't need to store the object in a
+ ** state provider.
+ *******************************************************************************/
+public class NonPersistedAsyncJobCallback extends AsyncJobCallback
+{
+ private static final QLogger LOG = QLogger.getLogger(NonPersistedAsyncJobCallback.class);
+ private final AsyncJobStatus asyncJobStatus;
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public NonPersistedAsyncJobCallback(UUID jobUUID, AsyncJobStatus asyncJobStatus)
+ {
+ super(jobUUID, asyncJobStatus);
+ this.asyncJobStatus = asyncJobStatus;
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ protected void storeUpdatedStatus()
+ {
+ ///////////////////////////////////////////////////////////
+ // todo - downgrade or remove this before merging to dev //
+ ///////////////////////////////////////////////////////////
+ LOG.info("Not persisting status from a NonPersistedAsyncJobCallback: " + asyncJobStatus.getJobName() + " / " + asyncJobStatus.getMessage());
+ }
+
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java
index 726ceea4..fab61595 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java
@@ -28,7 +28,6 @@ import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
-import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -364,7 +363,9 @@ public class QValueFormatter
}
}
- setDisplayValuesInRecord(fieldMap, record);
+ ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, QContext.getQInstance(), table, records, null);
+
+ setDisplayValuesInRecord(table, fieldMap, record, true);
record.setRecordLabel(formatRecordLabel(table, record));
}
}
@@ -374,61 +375,49 @@ public class QValueFormatter
/*******************************************************************************
** For a list of records, set their recordLabels and display values
*******************************************************************************/
- public static void setDisplayValuesInRecords(Collection fields, List records)
+ public static void setDisplayValuesInRecords(QTableMetaData table, Map fields, List records)
{
if(records == null)
{
return;
}
+ if(table != null)
+ {
+ ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, QContext.getQInstance(), table, records, null);
+ }
+
for(QRecord record : records)
{
- setDisplayValuesInRecord(fields, record);
+ setDisplayValuesInRecord(table, fields, record, true);
}
}
/*******************************************************************************
- ** For a list of records, set their recordLabels and display values
+ ** For a single record, set its display values - public version of this.
*******************************************************************************/
- public static void setDisplayValuesInRecords(Map fields, List records)
+ public static void setDisplayValuesInRecord(QTableMetaData table, Map fields, QRecord record)
{
- if(records == null)
- {
- return;
- }
-
- for(QRecord record : records)
- {
- setDisplayValuesInRecord(fields, record);
- }
+ setDisplayValuesInRecord(table, fields, record, false);
}
-
/*******************************************************************************
- ** For a list of records, set their display values
+ ** For a single record, set its display values - where caller (meant to stay private)
+ ** can specify if they've already done fieldBehaviors (to avoid re-doing).
*******************************************************************************/
- public static void setDisplayValuesInRecord(Collection fields, QRecord record)
+ private static void setDisplayValuesInRecord(QTableMetaData table, Map fields, QRecord record, boolean alreadyAppliedFieldDisplayBehaviors)
{
- for(QFieldMetaData field : fields)
+ if(!alreadyAppliedFieldDisplayBehaviors)
{
- if(record.getDisplayValue(field.getName()) == null)
+ if(table != null)
{
- String formattedValue = formatValue(field, record.getValue(field.getName()));
- record.setDisplayValue(field.getName(), formattedValue);
+ ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, QContext.getQInstance(), table, List.of(record), null);
}
}
- }
-
-
- /*******************************************************************************
- ** For a list of records, set their display values
- *******************************************************************************/
- public static void setDisplayValuesInRecord(Map fields, QRecord record)
- {
for(Map.Entry entry : fields.entrySet())
{
String fieldName = entry.getKey();
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplier.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplier.java
index 778df8f2..0338e34b 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplier.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplier.java
@@ -27,6 +27,7 @@ import java.util.Set;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldBehavior;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldDisplayBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
@@ -44,7 +45,8 @@ public class ValueBehaviorApplier
public enum Action
{
INSERT,
- UPDATE
+ UPDATE,
+ FORMATTING
}
@@ -63,7 +65,34 @@ public class ValueBehaviorApplier
{
for(FieldBehavior> fieldBehavior : CollectionUtils.nonNullCollection(field.getBehaviors()))
{
- fieldBehavior.apply(action, recordList, instance, table, field, behaviorsToOmit);
+ boolean applyBehavior = true;
+ if(behaviorsToOmit != null && behaviorsToOmit.contains(fieldBehavior))
+ {
+ /////////////////////////////////////////////////////////////////////////////////////////
+ // if we're given a set of behaviors to omit, and this behavior is in there, then skip //
+ /////////////////////////////////////////////////////////////////////////////////////////
+ applyBehavior = false;
+ }
+
+ if(Action.FORMATTING == action && !(fieldBehavior instanceof FieldDisplayBehavior>))
+ {
+ ////////////////////////////////////////////////////////////////////////////////////////////////
+ // for the formatting action, do not apply the behavior unless it is a field-display-behavior //
+ ////////////////////////////////////////////////////////////////////////////////////////////////
+ applyBehavior = false;
+ }
+ else if(Action.FORMATTING != action && fieldBehavior instanceof FieldDisplayBehavior>)
+ {
+ /////////////////////////////////////////////////////////////////////////////////////////////
+ // for non-formatting actions, do not apply the behavior IF it is a field-display-behavior //
+ /////////////////////////////////////////////////////////////////////////////////////////////
+ applyBehavior = false;
+ }
+
+ if(applyBehavior)
+ {
+ fieldBehavior.apply(action, recordList, instance, table, field);
+ }
}
}
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java
index ff86861f..85c7e307 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java
@@ -64,6 +64,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.ParentWidgetMetaD
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
@@ -810,7 +811,7 @@ public class QInstanceValidator
/*******************************************************************************
**
*******************************************************************************/
- private void validateTableField(QInstance qInstance, String tableName, String fieldName, QTableMetaData table, QFieldMetaData field)
+ private > void validateTableField(QInstance qInstance, String tableName, String fieldName, QTableMetaData table, QFieldMetaData field)
{
assertCondition(Objects.equals(fieldName, field.getName()),
"Inconsistent naming in table " + tableName + " for field " + fieldName + "/" + field.getName() + ".");
@@ -823,12 +824,32 @@ public class QInstanceValidator
String prefix = "Field " + fieldName + " in table " + tableName + " ";
+ ///////////////////////////////////////////////////
+ // validate things we know about field behaviors //
+ ///////////////////////////////////////////////////
ValueTooLongBehavior behavior = field.getBehaviorOrDefault(qInstance, ValueTooLongBehavior.class);
if(behavior != null && !behavior.equals(ValueTooLongBehavior.PASS_THROUGH))
{
assertCondition(field.getMaxLength() != null, prefix + "specifies a ValueTooLongBehavior, but not a maxLength.");
}
+ Set>> usedFieldBehaviorTypes = new HashSet<>();
+ if(field.getBehaviors() != null)
+ {
+ for(FieldBehavior> fieldBehavior : field.getBehaviors())
+ {
+ Class> behaviorClass = (Class>) fieldBehavior.getClass();
+
+ errors.addAll(fieldBehavior.validateBehaviorConfiguration(table, field));
+
+ if(!fieldBehavior.allowMultipleBehaviorsOfThisType())
+ {
+ assertCondition(!usedFieldBehaviorTypes.contains(behaviorClass), prefix + "has more than 1 fieldBehavior of type " + behaviorClass.getSimpleName() + ", which is not allowed for this type");
+ }
+ usedFieldBehaviorTypes.add(behaviorClass);
+ }
+ }
+
if(field.getMaxLength() != null)
{
assertCondition(field.getMaxLength() > 0, prefix + "has an invalid maxLength (" + field.getMaxLength() + ") - must be greater than 0.");
@@ -1449,7 +1470,7 @@ public class QInstanceValidator
private void validateScheduleMetaData(QScheduleMetaData schedule, QInstance qInstance, String prefix)
{
boolean isRepeat = schedule.getRepeatMillis() != null || schedule.getRepeatSeconds() != null;
- boolean isCron = StringUtils.hasContent(schedule.getCronExpression());
+ boolean isCron = StringUtils.hasContent(schedule.getCronExpression());
assertCondition(isRepeat || isCron, prefix + " either repeatMillis or repeatSeconds or cronExpression must be set");
assertCondition(!(isRepeat && isCron), prefix + " both a repeat time and cronExpression may not be set");
@@ -1469,8 +1490,8 @@ public class QInstanceValidator
if(assertCondition(StringUtils.hasContent(schedule.getCronTimeZoneId()), prefix + " a cron schedule must specify a cronTimeZoneId"))
{
- String[] availableIDs = TimeZone.getAvailableIDs();
- Optional first = Arrays.stream(availableIDs).filter(id -> id.equals(schedule.getCronTimeZoneId())).findFirst();
+ String[] availableIDs = TimeZone.getAvailableIDs();
+ Optional first = Arrays.stream(availableIDs).filter(id -> id.equals(schedule.getCronTimeZoneId())).findFirst();
assertCondition(first.isPresent(), prefix + " unrecognized cronTimeZoneId: " + schedule.getCronTimeZoneId());
}
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/AbstractActionInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/AbstractActionInput.java
index 429948d4..3c98715b 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/AbstractActionInput.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/AbstractActionInput.java
@@ -26,6 +26,7 @@ import java.util.UUID;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qqq.backend.core.actions.async.AsyncJobCallback;
import com.kingsrook.qqq.backend.core.actions.async.AsyncJobStatus;
+import com.kingsrook.qqq.backend.core.actions.async.NonPersistedAsyncJobCallback;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
@@ -139,7 +140,7 @@ public class AbstractActionInput
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// don't return null here (too easy to NPE). instead, if someone wants one of these, create one and give it to them. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
- asyncJobCallback = new AsyncJobCallback(UUID.randomUUID(), new AsyncJobStatus());
+ asyncJobCallback = new NonPersistedAsyncJobCallback(UUID.randomUUID(), new AsyncJobStatus().withJobName(getClass().getSimpleName()));
}
return asyncJobCallback;
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/processes/RunBackendStepInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/processes/RunBackendStepInput.java
index 18d63f75..bfaad833 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/processes/RunBackendStepInput.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/processes/RunBackendStepInput.java
@@ -30,6 +30,7 @@ import java.util.Map;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.actions.async.AsyncJobCallback;
import com.kingsrook.qqq.backend.core.actions.async.AsyncJobStatus;
+import com.kingsrook.qqq.backend.core.actions.async.NonPersistedAsyncJobCallback;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallback;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
@@ -450,7 +451,7 @@ public class RunBackendStepInput extends AbstractActionInput
/////////////////////////////////////////////////////////////////////////
// avoid NPE in case we didn't have one of these! create a new one... //
/////////////////////////////////////////////////////////////////////////
- asyncJobCallback = new AsyncJobCallback(UUID.randomUUID(), new AsyncJobStatus());
+ asyncJobCallback = new NonPersistedAsyncJobCallback(UUID.randomUUID(), new AsyncJobStatus().withJobName(processName + "." + stepName));
}
return (asyncJobCallback);
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/FieldValueListData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/FieldValueListData.java
index 33add9cd..6583e7c4 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/FieldValueListData.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/FieldValueListData.java
@@ -27,6 +27,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -147,7 +148,7 @@ public class FieldValueListData extends QWidgetData
}
}
- QValueFormatter.setDisplayValuesInRecord(fields, record);
+ QValueFormatter.setDisplayValuesInRecord(null, fields.stream().collect(Collectors.toMap(f -> f.getName(), f -> f)), record);
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehavior.java
new file mode 100644
index 00000000..5345a53d
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehavior.java
@@ -0,0 +1,331 @@
+/*
+ * 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.backend.core.model.metadata.fields;
+
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
+import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+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.StringUtils;
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
+
+
+/*******************************************************************************
+ ** Field Display Behavior class for customizing the display values used
+ ** in date-time fields
+ *******************************************************************************/
+public class DateTimeDisplayValueBehavior implements FieldDisplayBehavior
+{
+ private static final QLogger LOG = QLogger.getLogger(DateTimeDisplayValueBehavior.class);
+
+ private String zoneIdFromFieldName;
+ private String fallbackZoneId;
+
+ private String defaultZoneId;
+
+ private static DateTimeDisplayValueBehavior NOOP = new DateTimeDisplayValueBehavior();
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public DateTimeDisplayValueBehavior getDefault()
+ {
+ return NOOP;
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field)
+ {
+ if(StringUtils.hasContent(defaultZoneId))
+ {
+ applyDefaultZoneId(recordList, table, field);
+ }
+ else if(StringUtils.hasContent(zoneIdFromFieldName))
+ {
+ applyZoneIdFromFieldName(recordList, table, field);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private void applyDefaultZoneId(List recordList, QTableMetaData table, QFieldMetaData field)
+ {
+ for(QRecord record : CollectionUtils.nonNullList(recordList))
+ {
+ try
+ {
+ Instant instant = record.getValueInstant(field.getName());
+ ZonedDateTime zonedDateTime = instant.atZone(ZoneId.of(defaultZoneId));
+ record.setDisplayValue(field.getName(), QValueFormatter.formatDateTimeWithZone(zonedDateTime));
+ }
+ catch(Exception e)
+ {
+ LOG.info("Error applying defaultZoneId DateTimeDisplayValueBehavior", logPair("table", table.getName()), logPair("field", field.getName()), logPair("id", record.getValue(table.getPrimaryKeyField())));
+ }
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private void applyZoneIdFromFieldName(List recordList, QTableMetaData table, QFieldMetaData field)
+ {
+ for(QRecord record : CollectionUtils.nonNullList(recordList))
+ {
+ try
+ {
+ Instant instant = record.getValueInstant(field.getName());
+ String zoneString = record.getValueString(zoneIdFromFieldName);
+
+ ZoneId zoneId;
+ try
+ {
+ zoneId = ZoneId.of(zoneString);
+ }
+ catch(Exception e)
+ {
+ ////////////////////////////////////////////////////////////////////////////////////////////////
+ // if the zone string from the other field isn't valid, and we have a fallback, try to use it //
+ ////////////////////////////////////////////////////////////////////////////////////////////////
+ if(StringUtils.hasContent(fallbackZoneId))
+ {
+ zoneId = ZoneId.of(fallbackZoneId);
+ }
+ else
+ {
+ throw (e);
+ }
+ }
+
+ ZonedDateTime zonedDateTime = instant.atZone(zoneId);
+ record.setDisplayValue(field.getName(), QValueFormatter.formatDateTimeWithZone(zonedDateTime));
+ }
+ catch(Exception e)
+ {
+ LOG.info("Error applying zoneIdFromFieldName DateTimeDisplayValueBehavior", e, logPair("table", table.getName()), logPair("field", field.getName()), logPair("id", record.getValue(table.getPrimaryKeyField())));
+ }
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public List validateBehaviorConfiguration(QTableMetaData tableMetaData, QFieldMetaData fieldMetaData)
+ {
+ List errors = new ArrayList<>();
+ String errorSuffix = " field [" + fieldMetaData.getName() + "] in table [" + tableMetaData.getName() + "]";
+
+ if(!QFieldType.DATE_TIME.equals(fieldMetaData.getType()))
+ {
+ errors.add("A DateTimeDisplayValueBehavior was a applied to a non-DATE_TIME" + errorSuffix);
+ }
+
+ //////////////////////////////////////////////////
+ // validate rules if zoneIdFromFieldName is set //
+ //////////////////////////////////////////////////
+ if(StringUtils.hasContent(zoneIdFromFieldName))
+ {
+ if(StringUtils.hasContent(defaultZoneId))
+ {
+ errors.add("You may not specify both zoneIdFromFieldName and defaultZoneId in DateTimeDisplayValueBehavior on" + errorSuffix);
+ }
+
+ if(!tableMetaData.getFields().containsKey(zoneIdFromFieldName))
+ {
+ errors.add("Unrecognized field name [" + zoneIdFromFieldName + "] for [zoneIdFromFieldName] in DateTimeDisplayValueBehavior on" + errorSuffix);
+ }
+ else
+ {
+ QFieldMetaData zoneIdField = tableMetaData.getFields().get(zoneIdFromFieldName);
+ if(!QFieldType.STRING.equals(zoneIdField.getType()))
+ {
+ errors.add("A non-STRING type [" + zoneIdField.getType() + "] was specified as the zoneIdFromFieldName field [" + zoneIdFromFieldName + "] in DateTimeDisplayValueBehavior on" + errorSuffix);
+ }
+ }
+ }
+
+ ////////////////////////////////////////////
+ // validate rules if defaultZoneId is set //
+ ////////////////////////////////////////////
+ if(StringUtils.hasContent(defaultZoneId))
+ {
+ /////////////////////////////////////////////////////////////////////////////////////////////
+ // would check that you didn't specify from zoneIdFromFieldName - but that's covered above //
+ /////////////////////////////////////////////////////////////////////////////////////////////
+
+ if(StringUtils.hasContent(fallbackZoneId))
+ {
+ errors.add("You may not specify both defaultZoneId and fallbackZoneId in DateTimeDisplayValueBehavior on" + errorSuffix);
+ }
+
+ try
+ {
+ ZoneId.of(defaultZoneId);
+ }
+ catch(Exception e)
+ {
+ errors.add("Invalid ZoneId [" + defaultZoneId + "] for [defaultZoneId] in DateTimeDisplayValueBehavior on" + errorSuffix + "; " + e.getMessage());
+ }
+ }
+
+ /////////////////////////////////////////////
+ // validate rules if fallbackZoneId is set //
+ /////////////////////////////////////////////
+ if(StringUtils.hasContent(fallbackZoneId))
+ {
+ if(!StringUtils.hasContent(zoneIdFromFieldName))
+ {
+ errors.add("You may only set fallbackZoneId if using zoneIdFromFieldName in DateTimeDisplayValueBehavior on" + errorSuffix);
+ }
+
+ try
+ {
+ ZoneId.of(fallbackZoneId);
+ }
+ catch(Exception e)
+ {
+ errors.add("Invalid ZoneId [" + fallbackZoneId + "] for [fallbackZoneId] in DateTimeDisplayValueBehavior on" + errorSuffix + "; " + e.getMessage());
+ }
+ }
+
+ return (errors);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for zoneIdFromFieldName
+ *******************************************************************************/
+ public String getZoneIdFromFieldName()
+ {
+ return (this.zoneIdFromFieldName);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for zoneIdFromFieldName
+ *******************************************************************************/
+ public void setZoneIdFromFieldName(String zoneIdFromFieldName)
+ {
+ this.zoneIdFromFieldName = zoneIdFromFieldName;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for zoneIdFromFieldName
+ *******************************************************************************/
+ public DateTimeDisplayValueBehavior withZoneIdFromFieldName(String zoneIdFromFieldName)
+ {
+ this.zoneIdFromFieldName = zoneIdFromFieldName;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for defaultZoneId
+ *******************************************************************************/
+ public String getDefaultZoneId()
+ {
+ return (this.defaultZoneId);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for defaultZoneId
+ *******************************************************************************/
+ public void setDefaultZoneId(String defaultZoneId)
+ {
+ this.defaultZoneId = defaultZoneId;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for defaultZoneId
+ *******************************************************************************/
+ public DateTimeDisplayValueBehavior withDefaultZoneId(String defaultZoneId)
+ {
+ this.defaultZoneId = defaultZoneId;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for fallbackZoneId
+ *******************************************************************************/
+ public String getFallbackZoneId()
+ {
+ return (this.fallbackZoneId);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for fallbackZoneId
+ *******************************************************************************/
+ public void setFallbackZoneId(String fallbackZoneId)
+ {
+ this.fallbackZoneId = fallbackZoneId;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for fallbackZoneId
+ *******************************************************************************/
+ public DateTimeDisplayValueBehavior withFallbackZoneId(String fallbackZoneId)
+ {
+ this.fallbackZoneId = fallbackZoneId;
+ return (this);
+ }
+
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DynamicDefaultValueBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DynamicDefaultValueBehavior.java
index 9000598b..dbf47e6d 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DynamicDefaultValueBehavior.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DynamicDefaultValueBehavior.java
@@ -1,6 +1,6 @@
/*
* QQQ - Low-code Application Framework for Engineers.
- * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 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/
@@ -26,7 +26,6 @@ import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
-import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -70,16 +69,12 @@ public enum DynamicDefaultValueBehavior implements FieldBehavior recordList, QInstance instance, QTableMetaData table, QFieldMetaData field, Set> behaviorsToOmit)
+ public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field)
{
if(this.equals(NONE))
{
return;
}
- if(behaviorsToOmit != null && behaviorsToOmit.contains(this))
- {
- return;
- }
switch(this)
{
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldBehavior.java
index 215843df..6b2d92bf 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldBehavior.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldBehavior.java
@@ -1,6 +1,6 @@
/*
* QQQ - Low-code Application Framework for Engineers.
- * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 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/
@@ -22,8 +22,9 @@
package com.kingsrook.qqq.backend.core.model.metadata.fields;
+import java.util.Collections;
import java.util.List;
-import java.util.Set;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -34,8 +35,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
** Interface for (expected to be?) enums which define behaviors that get applied
** to fields.
**
- ** At the present, these behaviors get applied before a field is stored (insert
- ** or update), through the ValueBehaviorApplier class.
+ ** Some of these behaviors get applied before a field is stored (insert
+ ** or update), through the ValueBehaviorApplier class. Others can be used to
+ ** do more advanced display formatting than the displayFormat string alone can
+ ** do (see QValueFormatter).
**
*******************************************************************************/
public interface FieldBehavior>
@@ -45,12 +48,13 @@ public interface FieldBehavior>
** In case a behavior of this type wasn't set on the field, what should the
** default of this type be?
*******************************************************************************/
+ @JsonIgnore
T getDefault();
/*******************************************************************************
** Apply this behavior to a list of records
*******************************************************************************/
- void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field, Set> behaviorsToOmit);
+ void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field);
/*******************************************************************************
** control if multiple behaviors of this type should be allowed together on a field.
@@ -60,4 +64,14 @@ public interface FieldBehavior>
return (false);
}
+ /*******************************************************************************
+ ** allow this behavior to be validated during QInstance validation.
+ **
+ ** return a list of validation errors, if there are any.
+ *******************************************************************************/
+ default List validateBehaviorConfiguration(QTableMetaData tableMetaData, QFieldMetaData fieldMetaData)
+ {
+ return (Collections.emptyList());
+ }
+
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldDisplayBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldDisplayBehavior.java
new file mode 100644
index 00000000..c5150557
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldDisplayBehavior.java
@@ -0,0 +1,31 @@
+/*
+ * 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.backend.core.model.metadata.fields;
+
+
+/*******************************************************************************
+ **
+ *******************************************************************************/
+public interface FieldDisplayBehavior> extends FieldBehavior
+{
+
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/QFieldMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/QFieldMetaData.java
index a1d7ea4d..d78d469d 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/QFieldMetaData.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/QFieldMetaData.java
@@ -721,6 +721,17 @@ public class QFieldMetaData implements Cloneable
{
return (behaviorType.getEnumConstants()[0].getDefault());
}
+ else
+ {
+ try
+ {
+ return (behaviorType.getConstructor().newInstance().getDefault());
+ }
+ catch(Exception e)
+ {
+ LOG.warn("Error getting default behaviorType for [" + behaviorType.getSimpleName() + "]", e);
+ }
+ }
return (null);
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueTooLongBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueTooLongBehavior.java
index 77cd24cd..f439f7e6 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueTooLongBehavior.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueTooLongBehavior.java
@@ -1,6 +1,6 @@
/*
* QQQ - Low-code Application Framework for Engineers.
- * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 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/
@@ -23,7 +23,6 @@ package com.kingsrook.qqq.backend.core.model.metadata.fields;
import java.util.List;
-import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -66,16 +65,12 @@ public enum ValueTooLongBehavior implements FieldBehavior
**
*******************************************************************************/
@Override
- public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field, Set> behaviorsToOmit)
+ public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field)
{
if(this.equals(PASS_THROUGH))
{
return;
}
- if(behaviorsToOmit != null && behaviorsToOmit.contains(this))
- {
- return;
- }
String fieldName = field.getName();
if(!QFieldType.STRING.equals(field.getType()))
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/columnstats/ColumnStatsStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/columnstats/ColumnStatsStep.java
index 39dbd7f1..21584e8e 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/columnstats/ColumnStatsStep.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/columnstats/ColumnStatsStep.java
@@ -34,6 +34,7 @@ import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.stream.Collectors;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.DateTimeGroupBy;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
@@ -252,7 +253,7 @@ public class ColumnStatsStep implements BackendStep
QPossibleValueTranslator qPossibleValueTranslator = new QPossibleValueTranslator();
qPossibleValueTranslator.translatePossibleValuesInRecords(table, valueCounts, queryJoin == null ? null : List.of(queryJoin), null);
- QValueFormatter.setDisplayValuesInRecords(Map.of(fieldName, field, "count", countField), valueCounts);
+ QValueFormatter.setDisplayValuesInRecords(table, Map.of(fieldName, field, "count", countField), valueCounts);
runBackendStepOutput.addValue("valueCounts", valueCounts);
@@ -442,13 +443,13 @@ public class ColumnStatsStep implements BackendStep
}
QFieldMetaData percentField = new QFieldMetaData("percent", QFieldType.DECIMAL).withDisplayFormat(DisplayFormat.PERCENT_POINT2).withLabel("Percent");
- QValueFormatter.setDisplayValuesInRecords(Map.of(fieldName, field, "percent", percentField), valueCounts);
+ QValueFormatter.setDisplayValuesInRecords(table, Map.of(fieldName, field, "percent", percentField), valueCounts);
}
QInstanceEnricher qInstanceEnricher = new QInstanceEnricher(null);
fields.forEach(qInstanceEnricher::enrichField);
- QValueFormatter.setDisplayValuesInRecord(fields, statsRecord);
+ QValueFormatter.setDisplayValuesInRecord(table, fields.stream().collect(Collectors.toMap(f -> f.getName(), f -> f)), statsRecord);
runBackendStepOutput.addValue("statsFields", fields);
runBackendStepOutput.addValue("statsRecord", statsRecord);
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProvider.java
index d0c66403..e63a43ad 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProvider.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProvider.java
@@ -46,8 +46,9 @@ public class InMemoryStateProvider implements StateProviderInterface
private final Map map;
- private int jobPeriodSeconds = 60 * 15;
- private int jobInitialDelay = 60 * 60 * 4;
+ private static int jobPeriodSeconds = 60 * 60; // 1 hour
+ private static int cleanHours = 6;
+ private static int jobInitialDelay = 60 * 60 * cleanHours;
@@ -84,7 +85,7 @@ public class InMemoryStateProvider implements StateProviderInterface
{
try
{
- Instant cleanTime = Instant.now().minus(4, ChronoUnit.HOURS);
+ Instant cleanTime = Instant.now().minus(cleanHours, ChronoUnit.HOURS);
getInstance().clean(cleanTime);
}
catch(Exception e)
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java
index 893c2a41..8760b0fb 100644
--- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java
@@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.actions.values;
import java.math.BigDecimal;
+import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@@ -32,10 +33,13 @@ import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.List;
import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.DateTimeDisplayValueBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.TestUtils;
import org.junit.jupiter.api.Test;
@@ -210,4 +214,23 @@ class QValueFormatterTest extends BaseTest
assertEquals("2023-02-01 07:15:47 PM CST", QValueFormatter.formatDateTimeWithZone(ZonedDateTime.of(LocalDateTime.of(2023, Month.FEBRUARY, 1, 19, 15, 47), ZoneId.of("US/Central"))));
}
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testFieldDisplayBehaviors()
+ {
+ QInstance qInstance = QContext.getQInstance();
+ QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY);
+
+ table.withField(new QFieldMetaData("timeZone", QFieldType.STRING));
+ table.getField("createDate").withBehavior(new DateTimeDisplayValueBehavior().withZoneIdFromFieldName("timeZone"));
+
+ QRecord record = new QRecord().withValue("createDate", Instant.parse("2024-04-04T19:12:00Z")).withValue("timeZone", "America/Chicago");
+ QValueFormatter.setDisplayValuesInRecords(table, List.of(record));
+ assertEquals("2024-04-04 02:12:00 PM CDT", record.getDisplayValue("createDate"));
+ }
+
}
\ No newline at end of file
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplierTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplierTest.java
index 96f9d297..65c40b22 100644
--- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplierTest.java
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplierTest.java
@@ -29,9 +29,12 @@ import com.kingsrook.qqq.backend.core.BaseTest;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
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.fields.FieldBehavior;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldDisplayBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
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.TestUtils;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -140,6 +143,36 @@ class ValueBehaviorApplierTest extends BaseTest
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testApplyFormattingBehaviors()
+ {
+ QInstance qInstance = QContext.getQInstance();
+ QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY);
+
+ table.getField("firstName").withBehavior(ToUpperCaseBehavior.getInstance());
+ table.getField("lastName").withBehavior(ToUpperCaseBehavior.NOOP);
+ table.getField("ssn").withBehavior(ValueTooLongBehavior.TRUNCATE).withMaxLength(1);
+
+ QRecord record = new QRecord().withValue("firstName", "Homer").withValue("lastName", "Simpson").withValue("ssn", "0123456789");
+ ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, qInstance, table, List.of(record), null);
+
+ assertEquals("HOMER", record.getDisplayValue("firstName"));
+ assertNull(record.getDisplayValue("lastName")); // noop will literally do nothing, not even pass value through.
+ assertEquals("0123456789", record.getValueString("ssn")); // formatting action should not run the too-long truncate behavior
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // now put to-upper-case behavior on lastName, but run INSERT actions - and make sure it doesn't get applied. //
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ table.getField("lastName").withBehavior(ToUpperCaseBehavior.getInstance());
+ ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.INSERT, qInstance, table, List.of(record), null);
+ assertNull(record.getDisplayValue("lastName"));
+ }
+
+
+
/*******************************************************************************
**
*******************************************************************************/
@@ -153,4 +186,73 @@ class ValueBehaviorApplierTest extends BaseTest
return (recordOpt.get());
}
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static class ToUpperCaseBehavior implements FieldDisplayBehavior
+ {
+ private final boolean enabled;
+
+ private static ToUpperCaseBehavior NOOP = new ToUpperCaseBehavior(false);
+ private static ToUpperCaseBehavior instance = new ToUpperCaseBehavior(true);
+
+
+
+ /*******************************************************************************
+ ** Constructor
+ **
+ *******************************************************************************/
+ private ToUpperCaseBehavior(boolean enabled)
+ {
+ this.enabled = enabled;
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public ToUpperCaseBehavior getDefault()
+ {
+ return (NOOP);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static ToUpperCaseBehavior getInstance()
+ {
+ return (instance);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field)
+ {
+ if(!enabled)
+ {
+ return;
+ }
+
+ for(QRecord record : CollectionUtils.nonNullList(recordList))
+ {
+ String displayValue = record.getValueString(field.getName());
+ if(displayValue != null)
+ {
+ displayValue = displayValue.toUpperCase();
+ }
+
+ record.setDisplayValue(field.getName(), displayValue);
+ }
+ }
+ }
}
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java
index a98773c9..e0eec66e 100644
--- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java
@@ -26,6 +26,8 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
+import java.util.Set;
+import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
@@ -56,6 +58,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.DateTimeDisplayValueBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
@@ -1758,6 +1761,26 @@ public class QInstanceValidatorTest extends BaseTest
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testFieldBehaviors()
+ {
+ BiFunction fieldExtractor = (QInstance qInstance, String fieldName) -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).getField(fieldName);
+ assertValidationFailureReasons((qInstance -> fieldExtractor.apply(qInstance, "firstName").withBehaviors(Set.of(ValueTooLongBehavior.ERROR, ValueTooLongBehavior.TRUNCATE)).withMaxLength(1)),
+ "more than 1 fieldBehavior of type ValueTooLongBehavior, which is not allowed");
+
+ ///////////////////////////////////////////////////////////////////////////
+ // make sure a custom validation method in a field behavior gets applied //
+ // more tests for this particular behavior are in its own test class //
+ ///////////////////////////////////////////////////////////////////////////
+ assertValidationFailureReasons((qInstance -> fieldExtractor.apply(qInstance, "firstName").withBehavior(new DateTimeDisplayValueBehavior())),
+ "DateTimeDisplayValueBehavior was a applied to a non-DATE_TIME field");
+ }
+
+
+
/*******************************************************************************
**
*******************************************************************************/
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehaviorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehaviorTest.java
new file mode 100644
index 00000000..ca31fdc3
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehaviorTest.java
@@ -0,0 +1,169 @@
+/*
+ * 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.backend.core.model.metadata.fields;
+
+
+import java.time.Instant;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+/*******************************************************************************
+ ** Unit test for DateTimeDisplayValueBehavior
+ *******************************************************************************/
+class DateTimeDisplayValueBehaviorTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testZoneIdFromFieldName()
+ {
+ QInstance qInstance = QContext.getQInstance();
+ QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY);
+
+ table.withField(new QFieldMetaData("timeZone", QFieldType.STRING));
+ table.getField("createDate").withBehavior(new DateTimeDisplayValueBehavior().withZoneIdFromFieldName("timeZone"));
+
+ QRecord record = new QRecord().withValue("createDate", Instant.parse("2024-04-04T19:12:00Z")).withValue("timeZone", "America/Chicago");
+ ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, qInstance, table, List.of(record), null);
+ assertEquals("2024-04-04 02:12:00 PM CDT", record.getDisplayValue("createDate"));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testZoneIdFromFieldNameWithFallback()
+ {
+ QInstance qInstance = QContext.getQInstance();
+ QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY);
+
+ table.withField(new QFieldMetaData("timeZone", QFieldType.STRING));
+ table.getField("createDate").withBehavior(new DateTimeDisplayValueBehavior().withZoneIdFromFieldName("timeZone").withFallbackZoneId("America/Denver"));
+
+ QRecord record = new QRecord().withValue("createDate", Instant.parse("2024-04-04T19:12:00Z")).withValue("timeZone", "whodis");
+ ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, qInstance, table, List.of(record), null);
+ assertEquals("2024-04-04 01:12:00 PM MDT", record.getDisplayValue("createDate"));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testDefaultZoneId()
+ {
+ QInstance qInstance = QContext.getQInstance();
+ QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY);
+
+ table.withField(new QFieldMetaData("timeZone", QFieldType.STRING));
+ table.getField("createDate").withBehavior(new DateTimeDisplayValueBehavior().withDefaultZoneId("America/Los_Angeles"));
+
+ QRecord record = new QRecord().withValue("createDate", Instant.parse("2024-04-04T19:12:00Z"));
+ ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, qInstance, table, List.of(record), null);
+ assertEquals("2024-04-04 12:12:00 PM PDT", record.getDisplayValue("createDate"));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testValidation()
+ {
+ QInstance qInstance = QContext.getQInstance();
+ QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY);
+ QFieldMetaData field = table.getField("createDate");
+ table.withField(new QFieldMetaData("timeZone", QFieldType.STRING));
+
+ Function, List> testOne = setup ->
+ {
+ DateTimeDisplayValueBehavior dateTimeDisplayValueBehavior = new DateTimeDisplayValueBehavior();
+ setup.accept(dateTimeDisplayValueBehavior);
+ return (dateTimeDisplayValueBehavior.validateBehaviorConfiguration(table, field));
+ };
+
+ ///////////////////
+ // valid configs //
+ ///////////////////
+ assertThat(testOne.apply(b -> b.toString())).isEmpty(); // default setup (noop use-case) is valid
+ assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("timeZone"))).isEmpty();
+ assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("timeZone").withFallbackZoneId("UTC"))).isEmpty();
+ assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("timeZone").withFallbackZoneId("America/Chicago"))).isEmpty();
+ assertThat(testOne.apply(b -> b.withDefaultZoneId("UTC"))).isEmpty();
+ assertThat(testOne.apply(b -> b.withDefaultZoneId("America/Chicago"))).isEmpty();
+
+ /////////////////////
+ // invalid configs //
+ /////////////////////
+ assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("notAField")))
+ .hasSize(1).first().asString()
+ .contains("Unrecognized field name");
+
+ assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("id")))
+ .hasSize(1).first().asString()
+ .contains("A non-STRING type [INTEGER] was specified as the zoneIdFromFieldName field");
+
+ assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("timeZone").withDefaultZoneId("UTC")))
+ .hasSize(1).first().asString()
+ .contains("You may not specify both zoneIdFromFieldName and defaultZoneId");
+
+ assertThat(testOne.apply(b -> b.withDefaultZoneId("UTC").withFallbackZoneId("UTC")))
+ .hasSize(2)
+ .anyMatch(s -> s.contains("You may not specify both defaultZoneId and fallbackZoneId"))
+ .anyMatch(s -> s.contains("You may only set fallbackZoneId if using zoneIdFromFieldName"));
+
+ assertThat(testOne.apply(b -> b.withFallbackZoneId("UTC")))
+ .hasSize(1).first().asString()
+ .contains("You may only set fallbackZoneId if using zoneIdFromFieldName");
+
+ assertThat(testOne.apply(b -> b.withDefaultZoneId("notAZone")))
+ .hasSize(1).first().asString()
+ .contains("Invalid ZoneId [notAZone] for [defaultZoneId]");
+
+ assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("timeZone").withFallbackZoneId("notAZone")))
+ .hasSize(1).first().asString()
+ .contains("Invalid ZoneId [notAZone] for [fallbackZoneId]");
+
+ assertThat(new DateTimeDisplayValueBehavior().validateBehaviorConfiguration(table, table.getField("firstName")))
+ .hasSize(1).first().asString()
+ .contains("non-DATE_TIME field [firstName]");
+ }
+
+}
\ No newline at end of file