fix(custom-fields): reject over-long STRING values and reload help after publication #61

Merged
james.maes merged 1 commits from fix/custom-field-length-and-help-20261106 into develop 2026-11-06 15:32:04 +00:00
4 changed files with 66 additions and 2 deletions

No files matched your search

+2
View File
@@ -27,6 +27,8 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Fixed
- Bounded STRING custom fields carry QQQ's native `ValueTooLongBehavior.ERROR`, so a write longer than `maxLength` is rejected with a field error and the stored value is preserved, instead of passing through unchecked (the QQQ default `PASS_THROUGH`).
- Persisted help content is reloaded after each committed runtime metadata refresh, so sections and fields created by publishing a custom-field definition receive their stored `helpContent` rows without a restart.
- QBit metadata reports the Maven artifact version from a filtered resource, including release versions.
- `presence.changed` events and presence snapshots always carry `viewers`, sending `[]` once the last viewer leaves, as the schema requires (#52).
@@ -29,10 +29,12 @@ import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterfa
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceHelpContentManager;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Publish committed policy changes through the core overlay.
@@ -77,7 +79,7 @@ public class FeatureSettingsCustomizer implements TableCustomizerInterface
var instance = QContext.getQInstance();
if(transaction == null)
{
RuntimeMetaDataOverlay.refresh(instance);
refreshMetadata(instance);
}
else
{
@@ -86,7 +88,7 @@ public class FeatureSettingsCustomizer implements TableCustomizerInterface
{
try
{
QContext.withTemporaryContext(context, () -> RuntimeMetaDataOverlay.refresh(instance));
QContext.withTemporaryContext(context, () -> refreshMetadata(instance));
}
catch(QException e)
{
@@ -95,4 +97,13 @@ public class FeatureSettingsCustomizer implements TableCustomizerInterface
});
}
}
/*******************************************************************************
** Bind persisted help after the overlay creates its dynamic fields and sections.
*******************************************************************************/
private static void refreshMetadata(QInstance instance) throws QException
{
RuntimeMetaDataOverlay.refresh(instance);
QInstanceHelpContentManager.reloadHelpContent(instance);
}
}
@@ -43,6 +43,7 @@ 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.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueRangeBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
@@ -123,6 +124,10 @@ public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface
.withCriteriaExpander(new QCodeReference(FieldCriteriaExpander.class));
field.withLabel(definition.getValueString("label")).withIsRequired(json && Boolean.TRUE.equals(definition.getValueBoolean("required")))
.withMaxLength(definition.getValueInteger("maxLength"));
if(type == QFieldType.STRING && definition.getValueInteger("maxLength") != null)
{
field.withBehavior(ValueTooLongBehavior.ERROR);
}
if(definition.getValue("defaultValue") != null && json)
{
field.withDefaultValue(com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(type, definition.getValue("defaultValue")));
@@ -37,6 +37,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
@@ -44,9 +45,12 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContentMetaDataProvider;
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.JsonPathStorage;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
@@ -59,6 +63,48 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*******************************************************************************/
public class CustomFieldsTest extends PersonalServiceFixture
{
/*******************************************************************************
** Publication binds stored guidance to sections that did not exist at startup.
*******************************************************************************/
@Test
void publishedSectionReceivesStoredHelp() throws Exception
{
new HelpContentMetaDataProvider().defineAll(instance, "memory", table ->
table.withPermissionRules(new QPermissionRules()
.withLevel(PermissionLevel.NOT_PROTECTED)));
new QInstanceEnricher(instance).enrich();
admin();
var help = new InsertAction().execute(new InsertInput("helpContent").withRecords(List.of(new QRecord()
.withValue("key", "table:document;section:customFields").withValue("role", "ALL_SCREENS")
.withValue("format", "MARKDOWN").withValue("content", "Synthetic dynamic section guidance.")))).getRecords().getFirst();
assertThat(help.getErrors()).isNullOrEmpty();
QRecord definition = draft("guided", "STRING");
FieldDefinitions.apply(definition.getValue("id"), "Publish");
assertThat(instance.getTable("document").getSection("customFields").getHelpContents())
.anySatisfy(content -> assertThat(content.getContent()).contains("Synthetic dynamic section guidance."));
}
/*******************************************************************************
** A configured string bound rejects writes without replacing persisted values.
*******************************************************************************/
@Test
void publishedStringRejectsValuesBeyondItsMaximumLength() throws Exception
{
admin();
QRecord definition = draft("note", "STRING");
new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
new QRecord().withValue("id", definition.getValue("id")).withValue("maxLength", 5))));
FieldDefinitions.apply(definition.getValue("id"), "Publish");
login("alice");
var valid = new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(
new QRecord().withValue("id", 1).withValue("cf_note", "valid")))).getRecords().getFirst();
assertThat(valid.getErrors()).isNullOrEmpty();
var invalid = new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(
new QRecord().withValue("id", 1).withValue("cf_note", "too long")))).getRecords().getFirst();
assertThat(invalid.getErrors()).isNotEmpty();
assertThat(query("cf_note", QCriteriaOperator.EQUALS, "valid")).hasSize(1);
}
/*******************************************************************************
** Refreshing a SELECT must replace its generated vocabulary and allow later fields.
*******************************************************************************/