From 2f3938929b48a3553d3a3835b21d0e302ce5fc62 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 9 Mar 2026 09:00:03 -0500 Subject: [PATCH 1/2] feat(virtualFields): add baseline support for virtualFields --- ...stomizableTableViewsTablePersonalizer.java | 53 ++++++++++++ .../model/CustomizableTableFieldPVS.java | 14 +++- .../model/FieldAccessLevel.java | 3 +- .../customizers/TableViewFieldCustomizer.java | 2 +- ...izableTableViewsTablePersonalizerTest.java | 80 +++++++++++++++++++ .../model/CustomizableTableFieldPVSTest.java | 60 ++++++++++++++ .../model/FieldAccessLevelTest.java | 31 +++++++ .../TableViewFieldCustomizerTest.java | 22 +++++ 8 files changed, 262 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizer.java b/src/main/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizer.java index f46296d..4a20f38 100644 --- a/src/main/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizer.java +++ b/src/main/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizer.java @@ -72,6 +72,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.DynamicDefaultValueB import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAndJoinTable; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QSupplementalFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; @@ -178,6 +179,9 @@ public class CustomizableTableViewsTablePersonalizer implements TableMetaDataPer Map fieldsToKeep = getFieldsToKeepForTable(tableView, cloneTable, tableActionInput); cloneTable.setFields(fieldsToKeep); + Map virtualFieldsToKeep = getVirtualFieldsToKeepForTable(tableView, cloneTable, tableActionInput); + cloneTable.setVirtualFields(virtualFieldsToKeep); + /////////////////////////////////////////////////////////// // remove field names which aren't present from sections // /////////////////////////////////////////////////////////// @@ -360,6 +364,55 @@ public class CustomizableTableViewsTablePersonalizer implements TableMetaDataPer + /*************************************************************************** + * + ***************************************************************************/ + protected Map getVirtualFieldsToKeepForTable(TableView tableView, QTableMetaData cloneTable, AbstractTableActionInput tableActionInput) + { + Map cloneFields = cloneTable.getVirtualFields(); + if(cloneFields == null) + { + return Collections.emptyMap(); + } + + Map fieldsToKeep = new LinkedHashMap<>(); + + ///////////////////////////////////////////// + // figure out which fields the user gets // + // start with ones in the user's TableView // + ///////////////////////////////////////////// + for(TableViewField tableViewField : CollectionUtils.nonNullList(tableView.getFields())) + { + try + { + String fieldName = tableViewField.getFieldName().split("\\.")[1]; + QVirtualFieldMetaData fieldMetaData = cloneFields.get(fieldName); + if(fieldMetaData != null) + { + FieldAccessLevel fieldAccessLevel = FieldAccessLevel.getById(tableViewField.getAccessLevel()); + if(fieldAccessLevel != null) + { + fieldAccessLevel.apply(fieldMetaData); + } + + fieldsToKeep.put(fieldName, fieldMetaData); + } + } + catch(Exception e) + { + LOG.warn("Error processing tableViewField", e, logPair("fieldName", tableViewField.getFieldName())); + } + } + + /////////////////////////////////////////////////////////////////////////////// + // unlike normal fields, there are no 'always keep' rules for virtual fields // + /////////////////////////////////////////////////////////////////////////////// + + return fieldsToKeep; + } + + + /*************************************************************************** * check if any system-defined rules make it so that a field should always * be kept. diff --git a/src/main/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVS.java b/src/main/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVS.java index 4b1f502..b5857f7 100644 --- a/src/main/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVS.java +++ b/src/main/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVS.java @@ -96,6 +96,12 @@ public class CustomizableTableFieldPVS implements QCustomPossibleValueProvider(idString, fieldMetaData.getLabel())); } + + fieldMetaData = table.getVirtualFields().get(fieldName); + if(fieldMetaData != null) + { + return (new QPossibleValue<>(idString, fieldMetaData.getLabel())); + } } } @@ -128,7 +134,13 @@ public class CustomizableTableFieldPVS implements QCustomPossibleValueProvider fields = new ArrayList<>(table.getFields().values()); + if(table.getVirtualFields() != null) + { + fields.addAll(table.getVirtualFields().values()); + } + + for(QFieldMetaData field : fields) { if(field.getName().equals(table.getPrimaryKeyField())) { diff --git a/src/main/java/com/kingsrook/qbits/customizabletableviews/model/FieldAccessLevel.java b/src/main/java/com/kingsrook/qbits/customizabletableviews/model/FieldAccessLevel.java index 038e2b5..35808ea 100644 --- a/src/main/java/com/kingsrook/qbits/customizabletableviews/model/FieldAccessLevel.java +++ b/src/main/java/com/kingsrook/qbits/customizabletableviews/model/FieldAccessLevel.java @@ -24,6 +24,7 @@ package com.kingsrook.qbits.customizabletableviews.model; import java.util.Objects; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum; import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingPossibleValueEnum; @@ -204,7 +205,7 @@ public enum FieldAccessLevel implements PossibleValueEnum return ("This field is defined as hidden by the system - you may not make it " + getLabel()); } - if(!fieldMetaData.getIsEditable()) + if(!fieldMetaData.getIsEditable() || fieldMetaData instanceof QVirtualFieldMetaData) { if(this == READ_ONLY) { diff --git a/src/main/java/com/kingsrook/qbits/customizabletableviews/tables/customizers/TableViewFieldCustomizer.java b/src/main/java/com/kingsrook/qbits/customizabletableviews/tables/customizers/TableViewFieldCustomizer.java index 089f559..5e21127 100644 --- a/src/main/java/com/kingsrook/qbits/customizabletableviews/tables/customizers/TableViewFieldCustomizer.java +++ b/src/main/java/com/kingsrook/qbits/customizabletableviews/tables/customizers/TableViewFieldCustomizer.java @@ -64,7 +64,7 @@ public class TableViewFieldCustomizer implements TableCustomizerInterface String tableName = parts[0]; fieldName = parts[1]; - QFieldMetaData field = QContext.getQInstance().getTable(tableName).getField(fieldName); + QFieldMetaData field = QContext.getQInstance().getTable(tableName).getFieldOrVirtualField(fieldName); FieldAccessLevel fieldAccessLevel = FieldAccessLevel.getById(accessLevelId); if(fieldAccessLevel != null) diff --git a/src/test/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizerTest.java b/src/test/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizerTest.java index 4d081f4..7b47a5d 100644 --- a/src/test/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizerTest.java +++ b/src/test/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizerTest.java @@ -59,6 +59,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.metadata.fields.DynamicDefaultValueBehavior; 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.joins.JoinOn; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; @@ -499,6 +500,85 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testApplyViewToTableWithVirtualFields() + { + CustomizableTableViewsTablePersonalizer personalizer = new CustomizableTableViewsTablePersonalizer(); + TableMetaDataInput tableActionInput = new TableMetaDataInput(); + + QTableMetaData baseTable = new QTableMetaData() + .withName("vfTable") + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.STRING).withIsEditable(false)) + .withField(new QFieldMetaData("optional", QFieldType.STRING)) + .withVirtualField(new QVirtualFieldMetaData("computed", QFieldType.STRING).withLabel("Computed")) + .withVirtualField(new QVirtualFieldMetaData("derived", QFieldType.STRING).withLabel("Derived")) + .withSection(SectionFactory.defaultT1("id").withName("s0")) + .withSection(SectionFactory.defaultT2("optional").withName("s1")); + + ///////////////////////////////////////////////////////////////////////////////// + // empty view - no virtual fields kept (no "always keep" rules for virtuals) // + ///////////////////////////////////////////////////////////////////////////////// + QTableMetaData tableMetaData = personalizer.applyViewToTable( + new TableView().withFields(List.of()), + baseTable.clone(), tableActionInput); + + assertEquals(1, tableMetaData.getFields().size()); + assertNotNull(tableMetaData.getField("id")); + assertTrue(tableMetaData.getVirtualFields().isEmpty()); + + /////////////////////////////////////////////////////////////// + // view references one virtual field - only that one is kept // + /////////////////////////////////////////////////////////////// + tableMetaData = personalizer.applyViewToTable( + new TableView().withFields(List.of( + new TableViewField().withFieldName("vfTable.computed").withAccessLevel(READ_ONLY) + )), + baseTable.clone(), tableActionInput); + + assertEquals(1, tableMetaData.getFields().size()); + assertEquals(1, tableMetaData.getVirtualFields().size()); + assertNotNull(tableMetaData.getVirtualFields().get("computed")); + assertNull(tableMetaData.getVirtualFields().get("derived")); + + ///////////////////////////////////////////////////////////////////////////// + // view references both virtual fields and a regular field - all are kept // + ///////////////////////////////////////////////////////////////////////////// + tableMetaData = personalizer.applyViewToTable( + new TableView().withFields(List.of( + new TableViewField().withFieldName("vfTable.optional").withAccessLevel(EDITABLE_OPTIONAL), + new TableViewField().withFieldName("vfTable.computed").withAccessLevel(READ_ONLY), + new TableViewField().withFieldName("vfTable.derived").withAccessLevel(READ_ONLY) + )), + baseTable.clone(), tableActionInput); + + assertEquals(2, tableMetaData.getFields().size()); // id + optional + assertEquals(2, tableMetaData.getVirtualFields().size()); // computed + derived + + assertEquals(List.of("optional"), tableMetaData.getSection("s1").getFieldNames()); + + ////////////////////////////////////////////////////////////////// + // table with no virtual fields at all - should not break // + ////////////////////////////////////////////////////////////////// + QTableMetaData noVfTable = new QTableMetaData() + .withName("noVfTable") + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.STRING).withIsEditable(false)) + .withSection(SectionFactory.defaultT1("id").withName("s0")); + + tableMetaData = personalizer.applyViewToTable( + new TableView().withFields(List.of()), + noVfTable.clone(), tableActionInput); + + assertEquals(1, tableMetaData.getFields().size()); + assertTrue(tableMetaData.getVirtualFields() == null || tableMetaData.getVirtualFields().isEmpty()); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVSTest.java b/src/test/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVSTest.java index 664d567..3a775fd 100644 --- a/src/test/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVSTest.java +++ b/src/test/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVSTest.java @@ -32,11 +32,15 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput; 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.possiblevalues.QPossibleValue; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory; 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; /******************************************************************************* @@ -84,4 +88,60 @@ class CustomizableTableFieldPVSTest extends BaseTest assertEquals("Optional", possibleValues.get(1).getLabel()); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testWithVirtualFields() throws QException + { + QTableMetaData testTable = new QTableMetaData() + .withName("testTable") + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.STRING).withIsEditable(false)) + .withField(new QFieldMetaData("optional", QFieldType.STRING).withLabel("Optional")) + .withVirtualField(new QVirtualFieldMetaData("computed", QFieldType.STRING).withLabel("Computed")) + .withVirtualField(new QVirtualFieldMetaData("hiddenVirtual", QFieldType.STRING).withIsHidden(true).withLabel("Hidden Virtual")) + .withSection(SectionFactory.defaultT1("id").withName("s0")) + .withSection(SectionFactory.defaultT2("optional").withName("s1")); + QContext.getQInstance().addTable(testTable); + + new InsertAction().execute(new InsertInput(CustomizableTable.TABLE_NAME).withRecordEntities(List.of( + new CustomizableTable().withTableName(testTable.getName()).withIsActive(true)))); + + Integer tableViewId = 17; + new InsertAction().execute(new InsertInput(TableView.TABLE_NAME).withRecordEntities(List.of( + new TableView().withId(tableViewId).withCustomizableTableId(1).withName("a")))); + + ///////////////////////////////////////////////////////////////////// + // search should include the non-hidden virtual field in results // + ///////////////////////////////////////////////////////////////////// + List> possibleValues = new CustomizableTableFieldPVS().search(new SearchPossibleValueSourceInput().withOtherValues(Map.of("tableViewId", tableViewId))); + assertEquals(2, possibleValues.size()); + assertThat(possibleValues) + .anyMatch(pv1 -> "testTable.computed".equals(pv1.getId()) && "Computed".equals(pv1.getLabel())) + .anyMatch(pv1 -> "testTable.optional".equals(pv1.getId()) && "Optional".equals(pv1.getLabel())); + + /////////////////////////////////////////////////////////////////// + // getPossibleValue should resolve a virtual field by table.name // + /////////////////////////////////////////////////////////////////// + QPossibleValue pv = new CustomizableTableFieldPVS().getPossibleValue("testTable.computed"); + assertNotNull(pv); + assertEquals("testTable.computed", pv.getId()); + assertEquals("Computed", pv.getLabel()); + + ///////////////////////////////////////////////////// + // getPossibleValue for a normal field still works // + ///////////////////////////////////////////////////// + QPossibleValue pvNormal = new CustomizableTableFieldPVS().getPossibleValue("testTable.optional"); + assertNotNull(pvNormal); + assertEquals("Optional", pvNormal.getLabel()); + + ///////////////////////////////////////////////////////// + // getPossibleValue for a nonexistent field returns null // + ///////////////////////////////////////////////////////// + assertNull(new CustomizableTableFieldPVS().getPossibleValue("testTable.doesNotExist")); + } + } \ No newline at end of file diff --git a/src/test/java/com/kingsrook/qbits/customizabletableviews/model/FieldAccessLevelTest.java b/src/test/java/com/kingsrook/qbits/customizabletableviews/model/FieldAccessLevelTest.java index 31b2afc..75ad5fa 100644 --- a/src/test/java/com/kingsrook/qbits/customizabletableviews/model/FieldAccessLevelTest.java +++ b/src/test/java/com/kingsrook/qbits/customizabletableviews/model/FieldAccessLevelTest.java @@ -25,12 +25,16 @@ package com.kingsrook.qbits.customizabletableviews.model; import java.util.function.BiFunction; import com.kingsrook.qbits.customizabletableviews.BaseTest; 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 org.junit.jupiter.api.Test; import static com.kingsrook.qbits.customizabletableviews.QFieldMetaDataAssert.assertThat; import static com.kingsrook.qbits.customizabletableviews.model.FieldAccessLevel.EDITABLE_OPTIONAL; import static com.kingsrook.qbits.customizabletableviews.model.FieldAccessLevel.EDITABLE_REQUIRED; import static com.kingsrook.qbits.customizabletableviews.model.FieldAccessLevel.READ_ONLY; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; /******************************************************************************* @@ -122,4 +126,31 @@ class FieldAccessLevelTest extends BaseTest assertThat(doApply.apply(READ_ONLY, hiddenField))/*....*/.isHidden().isNotRequired().isEditable(); // no changes } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testValidateForVirtualField() + { + /////////////////////////////////////////////////////////////////////////////////// + // a virtual field (even if editable) should be treated as read-only by validate // + /////////////////////////////////////////////////////////////////////////////////// + QVirtualFieldMetaData editableVirtualField = new QVirtualFieldMetaData("vf", QFieldType.STRING).withIsEditable(true); + + assertNull(READ_ONLY.validateForField(editableVirtualField)); + assertNotNull(EDITABLE_REQUIRED.validateForField(editableVirtualField)); + assertNotNull(EDITABLE_OPTIONAL.validateForField(editableVirtualField)); + + ///////////////////////////////////////////////// + // a non-editable virtual field - same results // + ///////////////////////////////////////////////// + QVirtualFieldMetaData readOnlyVirtualField = new QVirtualFieldMetaData("vf", QFieldType.STRING).withIsEditable(false); + + assertNull(READ_ONLY.validateForField(readOnlyVirtualField)); + assertNotNull(EDITABLE_REQUIRED.validateForField(readOnlyVirtualField)); + assertNotNull(EDITABLE_OPTIONAL.validateForField(readOnlyVirtualField)); + } + } \ No newline at end of file diff --git a/src/test/java/com/kingsrook/qbits/customizabletableviews/tables/customizers/TableViewFieldCustomizerTest.java b/src/test/java/com/kingsrook/qbits/customizabletableviews/tables/customizers/TableViewFieldCustomizerTest.java index 65c06e8..bb0d21d 100644 --- a/src/test/java/com/kingsrook/qbits/customizabletableviews/tables/customizers/TableViewFieldCustomizerTest.java +++ b/src/test/java/com/kingsrook/qbits/customizabletableviews/tables/customizers/TableViewFieldCustomizerTest.java @@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException; 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.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -74,6 +75,27 @@ class TableViewFieldCustomizerTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testVirtualField() throws QException + { + QContext.getQInstance().addTable(new QTableMetaData() + .withName("testTable2") + .withField(new QFieldMetaData("id", QFieldType.STRING)) + .withVirtualField(new QVirtualFieldMetaData("virtualField", QFieldType.STRING).withLabel("Virtual"))); + + ////////////////////////////////////////////////////////////////////////////// + // virtual fields are treated as read-only, so only READ_ONLY should pass. // + ////////////////////////////////////////////////////////////////////////////// + assertNoError(new TableViewField().withFieldName("testTable2.virtualField").withAccessLevel(FieldAccessLevel.READ_ONLY)); + assertError(new TableViewField().withFieldName("testTable2.virtualField").withAccessLevel(FieldAccessLevel.EDITABLE_REQUIRED)); + assertError(new TableViewField().withFieldName("testTable2.virtualField").withAccessLevel(FieldAccessLevel.EDITABLE_OPTIONAL)); + } + + + /*************************************************************************** * ***************************************************************************/ -- 2.54.0 From 5c97a017dc3fea5afe3f9dd56d10030dfa2e0e34 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 9 Mar 2026 11:59:59 -0500 Subject: [PATCH 2/2] fix: avoid NPE if table has no virtual fields --- .../customizabletableviews/model/CustomizableTableFieldPVS.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVS.java b/src/main/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVS.java index b5857f7..53ec048 100644 --- a/src/main/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVS.java +++ b/src/main/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableFieldPVS.java @@ -97,7 +97,7 @@ public class CustomizableTableFieldPVS implements QCustomPossibleValueProvider(idString, fieldMetaData.getLabel())); } - fieldMetaData = table.getVirtualFields().get(fieldName); + fieldMetaData = table.getVirtualFields() == null ? null : table.getVirtualFields().get(fieldName); if(fieldMetaData != null) { return (new QPossibleValue<>(idString, fieldMetaData.getLabel())); -- 2.54.0