diff --git a/.circleci/config.yml b/.circleci/config.yml index 609a8dc..f632b2c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,7 +13,7 @@ workflows: branches: ignore: /(develop|main|release/.*|hotfix/.*|integration.*)/ tags: - only: [] + only: [] publish_snapshot: jobs: diff --git a/README.md b/README.md index d6e3032..cd96b0c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,18 @@ -# qbit-customizable-table-views +# QBit: Customizable Table Views +[![Build Status](https://dl.circleci.com/status-badge/img/gh/Kingsrook/qbit-customizable-table-views/tree/develop.svg?style=svg)](https://circleci.com/gh/Kingsrook/qbit-customizable-table-views) +[![Maven Central](https://img.shields.io/maven-central/v/com.kingsrook.qbits/qbit-customizable-table-views.svg)](https://central.sonatype.com/namespace/com.kingsrook.qbits) +[![Java](https://img.shields.io/badge/java-17+-blue.svg)](https://adoptium.net/) +[![License](https://img.shields.io/badge/license-AGPL%203.0-green.svg)](LICENSE.txt) + +**QBit Customizable Table Views** provides table personalization capabilities for QQQ applications, allowing users to customize which fields and widgets they see based on their roles and permissions. + +## 🚀 What Makes This QBit Different? + +**Role-Based Table Customization** - This QBit enables fine-grained control over table presentation without modifying application code. Users see different fields and widgets based on their assigned roles, providing a personalized experience while maintaining security and data integrity. + +## 🔓 Open Source & Full Control + +**This QBit is 100% open source** - you have complete ownership and control: User-defined saved views for QQQ dashboard tables. diff --git a/pom.xml b/pom.xml index 475f5ce..18d62e9 100644 --- a/pom.xml +++ b/pom.xml @@ -20,8 +20,7 @@ ~ along with this program. If not, see . --> - - 4.0.0 + com.kingsrook @@ -31,13 +30,15 @@ com.kingsrook.qbits qbit-customizable-table-views - ${revision} - jar + 0.4.0-SNAPSHOT + jar QBit Customizable Table Views Customizable table views module for the QBit framework, providing table personalization and field/widget access control https://github.com/Kingsrook/qbit-customizable-table-views + 4.0.0 + GNU Affero General Public License v3.0 @@ -64,23 +65,15 @@ - 0.3.0 + 0.4.0-RC.1 - com.kingsrook.qqq qqq-bom-pom - 0.27.9 - pom - import - - - org.junit - junit-bom - 5.10.0 + 0.40.0-SNAPSHOT pom import @@ -95,6 +88,13 @@ com.kingsrook.qqq qqq-backend-module-rdbms + test + + + com.h2database + h2 + 2.2.220 + test @@ -110,4 +110,11 @@ + + + sonatype-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + + 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 4b55671..4a20f38 100644 --- a/src/main/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizer.java +++ b/src/main/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizer.java @@ -25,8 +25,10 @@ package com.kingsrook.qbits.customizabletableviews.logic; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -35,6 +37,7 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import com.kingsrook.qbits.customizabletableviews.model.CustomizableTable; +import com.kingsrook.qbits.customizabletableviews.model.CustomizableTableViewsFieldMetaData; import com.kingsrook.qbits.customizabletableviews.model.FieldAccessLevel; import com.kingsrook.qbits.customizabletableviews.model.TableView; import com.kingsrook.qbits.customizabletableviews.model.TableViewField; @@ -56,14 +59,22 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInpu import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin; +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.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.DynamicDefaultValueBehavior; +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; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.session.QUser; @@ -103,7 +114,6 @@ public class CustomizableTableViewsTablePersonalizer implements TableMetaDataPer - /*************************************************************************** * ***************************************************************************/ @@ -153,7 +163,7 @@ public class CustomizableTableViewsTablePersonalizer implements TableMetaDataPer if(tableView != null) { - return applyViewToTable(tableView, tableActionInput.getTable().clone()); + return applyViewToTable(tableView, tableActionInput.getTable().clone(), tableActionInput); } return tableActionInput.getTable(); @@ -164,69 +174,60 @@ public class CustomizableTableViewsTablePersonalizer implements TableMetaDataPer /*************************************************************************** * ***************************************************************************/ - QTableMetaData applyViewToTable(TableView tableView, QTableMetaData cloneTable) + QTableMetaData applyViewToTable(TableView tableView, QTableMetaData cloneTable, AbstractTableActionInput tableActionInput) { - Map cloneFields = cloneTable.getFields(); - if(cloneFields == null) - { - cloneFields = new LinkedHashMap<>(); - cloneTable.setFields(cloneFields); - } - - Map fieldsToKeep = new LinkedHashMap<>(); - - /////////////////////////////////////////// - // figure out which fields the user gets // - /////////////////////////////////////////// - for(TableViewField tableViewField : CollectionUtils.nonNullList(tableView.getFields())) - { - try - { - String fieldName = tableViewField.getFieldName().split("\\.")[1]; - QFieldMetaData 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())); - } - } - - /////////////////////////////////////////////////////////// - // always keep the primary key field and required fields // - /////////////////////////////////////////////////////////// - Set requiredAndPrimaryKeyFields = new HashSet<>(); - for(QFieldMetaData field : cloneTable.getFields().values()) - { - String fieldName = field.getName(); - if(field.getIsRequired() || Objects.equals(fieldName, cloneTable.getPrimaryKeyField())) - { - if(!fieldsToKeep.containsKey(fieldName)) - { - fieldsToKeep.put(fieldName, cloneFields.get(fieldName)); - } - } - } - + 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 // /////////////////////////////////////////////////////////// for(QFieldSection section : CollectionUtils.nonNullList(cloneTable.getSections())) { - if(section.getFieldNames() != null) + Map> getFieldsToKeepFromJoinTableCache = null; + + Iterator fieldListIterator = CollectionUtils.nonNullList(section.getFieldNames()).iterator(); + while(fieldListIterator.hasNext()) { - section.getFieldNames().removeIf(next -> !fieldsToKeep.containsKey(next)); + try + { + String fieldName = fieldListIterator.next(); + FieldAndJoinTable fieldAndJoinTable = FieldAndJoinTable.get(cloneTable, fieldName); + + if(fieldAndJoinTable.joinTable().getName().equals(cloneTable.getName())) + { + //////////////////////////////////////////////////////////////////////////////////////////// + // if the field is from this table, then remove it unless it's in the set of ones to keep // + //////////////////////////////////////////////////////////////////////////////////////////// + if(!fieldsToKeep.containsKey(fieldName)) + { + fieldListIterator.remove(); + } + } + else + { + getFieldsToKeepFromJoinTableCache = Objects.requireNonNullElseGet(getFieldsToKeepFromJoinTableCache, HashMap::new); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else the field is from a join table - get that join table's allowed fields for the user, and keep or remove based on that // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + Set fieldsToKeepFromJoinTable = getFieldsToKeepFromJoinTable(fieldAndJoinTable.joinTable().getName(), tableActionInput, getFieldsToKeepFromJoinTableCache); + if(!fieldsToKeepFromJoinTable.contains(fieldAndJoinTable.field().getName())) + { + fieldListIterator.remove(); + } + } + } + catch(Exception e) + { + ///////////////////////////////////////////////////////// + // an unknown field, let's assume it should be removed // + ///////////////////////////////////////////////////////// + fieldListIterator.remove(); + } } } @@ -266,6 +267,258 @@ public class CustomizableTableViewsTablePersonalizer implements TableMetaDataPer + /*************************************************************************** + * + ***************************************************************************/ + protected Set getFieldsToKeepFromJoinTable(String joinTableName, AbstractTableActionInput tableActionInput, Map> getFieldsToKeepFromJoinTableCache) + { + return (getFieldsToKeepFromJoinTableCache.computeIfAbsent(joinTableName, k -> + { + try + { + if(isTableCustomizable(joinTableName)) + { + //////////////////////////////////////////////////////////////// + // if the table is customizable, return set of fields to keep // + //////////////////////////////////////////////////////////////// + QTableMetaData joinTableClone = QContext.getQInstance().getTable(joinTableName).clone(); + TableView joinTableView = getEffectiveTableViewForCurrentSession(joinTableName); + Map joinTableFieldsToKeep = getFieldsToKeepForTable(joinTableView, joinTableClone, tableActionInput); + return joinTableFieldsToKeep.keySet(); + } + else + { + /////////////////////////////////////////////// + // else, not customizable, return all fields // + /////////////////////////////////////////////// + return (QContext.getQInstance().getTable(joinTableName).getFields().keySet()); + } + } + catch(QException e) + { + return Collections.emptySet(); + } + })); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + protected Map getFieldsToKeepForTable(TableView tableView, QTableMetaData cloneTable, AbstractTableActionInput tableActionInput) + { + Map cloneFields = cloneTable.getFields(); + if(cloneFields == null) + { + cloneFields = new LinkedHashMap<>(); + cloneTable.setFields(cloneFields); + } + + 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]; + QFieldMetaData 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())); + } + } + + ///////////////////////////////////////////// + // next look for ones that always get kept // + ///////////////////////////////////////////// + for(QFieldMetaData field : cloneTable.getFields().values()) + { + String fieldName = field.getName(); + if(!fieldsToKeep.containsKey(fieldName)) + { + if(shouldKeepFieldDueToRules(cloneTable, field, tableActionInput)) + { + fieldsToKeep.put(fieldName, cloneFields.get(fieldName)); + } + } + } + + return fieldsToKeep; + } + + + + /*************************************************************************** + * + ***************************************************************************/ + 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. + * + *

built-in rules are:

+ *
    + *
  • field is required
  • + *
  • field is primary key
  • + *
  • field is part of table's security locks
  • + *
  • there's supplemental metadata from this qbit that says to keep the field
  • + *
  • this is an insert or update, and there's a {@link DynamicDefaultValueBehavior}
  • + *
+ * + * also - an application could override this method in a subclass to add + * additional rules. + ***************************************************************************/ + protected boolean shouldKeepFieldDueToRules(QTableMetaData cloneTable, QFieldMetaData field, AbstractTableActionInput tableActionInput) + { + if(field.getIsRequired() || Objects.equals(field.getName(), cloneTable.getPrimaryKeyField())) + { + /////////////////////////////////////////////////////////// + // always keep the primary key field and required fields // + /////////////////////////////////////////////////////////// + return (true); + } + + if(doRecordSecurityLocksContainField(cloneTable.getRecordSecurityLocks(), field)) + { + ///////////////////////////////////////////////////////////// + // keep fields that are part of the table's security locks // + ///////////////////////////////////////////////////////////// + return (true); + } + + if(doesSupplementalMetaDataSayToKeepField(field)) + { + ////////////////////////////////////////////////////////// + // keep a field if it's marked as being always-included // + ////////////////////////////////////////////////////////// + return (true); + } + + boolean isInsertOrUpdate = (tableActionInput instanceof InsertInput) || (tableActionInput instanceof UpdateInput); + if(isInsertOrUpdate && field.getBehaviorOnlyIfSet(DynamicDefaultValueBehavior.class) != null) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // if this is for an insert or update, then keep any fields that have a DynamicDefaultValueBehavior // + // e.g., createDates, modifyDates, things that capture userId // + ////////////////////////////////////////////////////////////////////////////////////////////////////// + return (true); + } + + //////////////////////////////////////////// + // by default, no rule matched, so say no // + //////////////////////////////////////////// + return (false); + } + + + + /*************************************************************************** + * Check if the supplemental meta data from this qbit for a field says + * that the field should always be kept. + ***************************************************************************/ + protected boolean doesSupplementalMetaDataSayToKeepField(QFieldMetaData field) + { + QSupplementalFieldMetaData supplementalMetaData = field.getSupplementalMetaData(CustomizableTableViewsFieldMetaData.TYPE); + if(supplementalMetaData instanceof CustomizableTableViewsFieldMetaData customizableTableViewsFieldMetaData) + { + if(CustomizableTableViewsFieldMetaData.Rule.ALWAYS_KEEP_FIELD.equals(customizableTableViewsFieldMetaData.getRule())) + { + return (true); + } + } + + return (false); + } + + + + /*************************************************************************** + * Check if a list of record security locks include a field, recursively + * processing {@link MultiRecordSecurityLock}s + ***************************************************************************/ + protected boolean doRecordSecurityLocksContainField(List locks, QFieldMetaData field) + { + for(RecordSecurityLock recordSecurityLock : CollectionUtils.nonNullList(locks)) + { + if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) + { + if(doRecordSecurityLocksContainField(multiRecordSecurityLock.getLocks(), field)) + { + return (true); + } + } + else if(field.getName().equals(recordSecurityLock.getFieldName())) + { + return true; + } + } + + return false; + } + + + /*************************************************************************** * the assumption is, that this method is called AFTER we've identified that * a table is customizable (and customizaztion is active) - so - if we fail @@ -294,7 +547,9 @@ public class CustomizableTableViewsTablePersonalizer implements TableMetaDataPer { Set roleIds = Arrays.stream(roleIdsString.split(",")).map(Integer::parseInt).collect(Collectors.toSet()); tableViews = new QueryAction().execute(new QueryInput(TableView.TABLE_NAME) - .withFilter(new QQueryFilter().withCriteria(new QFilterCriteria(TableViewRoleInt.TABLE_NAME + ".roleId", QCriteriaOperator.IN, roleIds))) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria(TableViewRoleInt.TABLE_NAME + ".roleId", QCriteriaOperator.IN, roleIds)) + .withCriteria(new QFilterCriteria(CustomizableTable.TABLE_NAME + ".tableName", QCriteriaOperator.EQUALS, tableName))) .withQueryJoin(new QueryJoin(TableViewRoleInt.TABLE_NAME)) .withIncludeAssociations(true)) .getRecords(); 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..53ec048 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() == null ? null : 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/CustomizableTableViewsFieldMetaData.java b/src/main/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableViewsFieldMetaData.java new file mode 100644 index 0000000..a3163ed --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/customizabletableviews/model/CustomizableTableViewsFieldMetaData.java @@ -0,0 +1,89 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.customizabletableviews.model; + + +import com.kingsrook.qqq.backend.core.model.metadata.fields.QSupplementalFieldMetaData; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class CustomizableTableViewsFieldMetaData extends QSupplementalFieldMetaData +{ + public static final String TYPE = CustomizableTableViewsFieldMetaData.class.getName(); + + private Rule rule; + + public enum Rule + { + ALWAYS_KEEP_FIELD + } + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public String getType() + { + return TYPE; + } + + + /******************************************************************************* + * Getter for rule + * @see #withRule(Rule) + *******************************************************************************/ + public Rule getRule() + { + return (this.rule); + } + + + + /******************************************************************************* + * Setter for rule + * @see #withRule(Rule) + *******************************************************************************/ + public void setRule(Rule rule) + { + this.rule = rule; + } + + + + /******************************************************************************* + * Fluent setter for rule + * + * @param rule + * TODO document this property + * + * @return this + *******************************************************************************/ + public CustomizableTableViewsFieldMetaData withRule(Rule rule) + { + this.rule = rule; + return (this); + } + + +} 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 913591f..7b47a5d 100644 --- a/src/test/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizerTest.java +++ b/src/test/java/com/kingsrook/qbits/customizabletableviews/logic/CustomizableTableViewsTablePersonalizerTest.java @@ -34,6 +34,7 @@ import java.util.stream.Collectors; import com.kingsrook.qbits.customizabletableviews.BaseTest; import com.kingsrook.qbits.customizabletableviews.QFieldMetaDataAssert; import com.kingsrook.qbits.customizabletableviews.model.CustomizableTable; +import com.kingsrook.qbits.customizabletableviews.model.CustomizableTableViewsFieldMetaData; import com.kingsrook.qbits.customizabletableviews.model.FieldAccessLevel; import com.kingsrook.qbits.customizabletableviews.model.TableView; import com.kingsrook.qbits.customizabletableviews.model.TableViewField; @@ -44,6 +45,8 @@ import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; +import com.kingsrook.qqq.backend.core.model.actions.metadata.TableMetaDataInput; import com.kingsrook.qqq.backend.core.model.actions.metadata.personalization.TableMetaDataPersonalizerInput; import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; @@ -51,8 +54,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +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; +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.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory; import com.kingsrook.qqq.backend.core.model.session.QUser; @@ -262,8 +274,10 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest { CustomizableTableViewsTablePersonalizer personalizer = new CustomizableTableViewsTablePersonalizer(); - String tableWithoutDefaultView = "tableWithoutDefaultView"; - String tableWithDefaultView = "tableWithDefaultView"; + String tableWithoutDefaultView = "tableWithoutDefaultView"; + String tableWithDefaultView = "tableWithDefaultView"; + Integer tableWithoutDefaultViewId = 1; + Integer tableWithDefaultViewId = 2; QContext.getQInstance().addTable(new QTableMetaData() .withName(tableWithDefaultView) @@ -272,8 +286,8 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest .withField(new QFieldMetaData("c", QFieldType.STRING))); new InsertAction().execute(new InsertInput(CustomizableTable.TABLE_NAME).withRecordEntities(List.of( - new CustomizableTable().withId(1).withTableName(tableWithoutDefaultView).withDefaultTableViewId(null), - new CustomizableTable().withId(2).withTableName(tableWithDefaultView).withDefaultTableViewId(1) + new CustomizableTable().withId(tableWithoutDefaultViewId).withTableName(tableWithoutDefaultView).withDefaultTableViewId(null), + new CustomizableTable().withId(tableWithDefaultViewId).withTableName(tableWithDefaultView).withDefaultTableViewId(1) ))); ////////////////////// @@ -293,6 +307,13 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest QContext.getQSession().setValue("roleIds", ""); assertEmptyView(personalizer.getEffectiveTableViewForCurrentSession(tableWithoutDefaultView)); + ///////////////////////////////////////////////////////////////////////////////////////////////// + // put roles in session - there still aren't any view-role-int's for this table, so empty view // + ///////////////////////////////////////////////////////////////////////////////////////////////// + QContext.getQSession().setUser(new QUser().withIdReference(UUID.randomUUID().toString())); + QContext.getQSession().setValue("roleIds", "1,2"); + assertEmptyView(personalizer.getEffectiveTableViewForCurrentSession(tableWithoutDefaultView)); + ////////////////////////////////////////////////////////////////////////////////////// // this table has a defaultViewId, but that view doesn't exist, so get null for now // ////////////////////////////////////////////////////////////////////////////////////// @@ -300,31 +321,24 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest QContext.getQSession().setValue("roleIds", null); assertEmptyView(personalizer.getEffectiveTableViewForCurrentSession(tableWithDefaultView)); - ///////////////////// - // insert the view // - ///////////////////// + //////////////////////////////////////////////// + // insert that default view, and a few others // + //////////////////////////////////////////////// new InsertAction().execute(new InsertInput(TableView.TABLE_NAME).withRecordEntities(List.of( - new TableView().withId(1).withCustomizableTableId(2).withName("a").withFields(List.of(new TableViewField().withFieldName(tableWithDefaultView + ".a").withAccessLevel(EDITABLE_OPTIONAL))), - new TableView().withId(2).withCustomizableTableId(2).withName("b").withFields(List.of(new TableViewField().withFieldName(tableWithDefaultView + ".b").withAccessLevel(EDITABLE_OPTIONAL))), - new TableView().withId(3).withCustomizableTableId(2).withName("c").withFields(List.of(new TableViewField().withFieldName(tableWithDefaultView + ".c").withAccessLevel(EDITABLE_OPTIONAL))) + new TableView().withId(1).withCustomizableTableId(tableWithDefaultViewId).withName("a").withFields(List.of(new TableViewField().withFieldName(tableWithDefaultView + ".a").withAccessLevel(EDITABLE_OPTIONAL))), + new TableView().withId(2).withCustomizableTableId(tableWithDefaultViewId).withName("b").withFields(List.of(new TableViewField().withFieldName(tableWithDefaultView + ".b").withAccessLevel(EDITABLE_OPTIONAL))), + new TableView().withId(3).withCustomizableTableId(tableWithDefaultViewId).withName("c").withFields(List.of(new TableViewField().withFieldName(tableWithDefaultView + ".c").withAccessLevel(EDITABLE_OPTIONAL))) ))); - /////////////////////// - // now we can get it // - /////////////////////// + //////////////////////////////////////////////////// + // now we can get the default view for this table // + //////////////////////////////////////////////////// QContext.getQSession().setUser(new QUser().withIdReference(UUID.randomUUID().toString())); QContext.getQSession().setValue("roleIds", null); assertThat(personalizer.getEffectiveTableViewForCurrentSession(tableWithDefaultView)) .isNotNull() .extracting("name").isEqualTo("a"); - ///////////////////////////////////////////////////////////// - // put roles in session - but fail to find a view-role-int // - ///////////////////////////////////////////////////////////// - QContext.getQSession().setUser(new QUser().withIdReference(UUID.randomUUID().toString())); - QContext.getQSession().setValue("roleIds", "1,2"); - assertEmptyView(personalizer.getEffectiveTableViewForCurrentSession(tableWithoutDefaultView)); - /////////////////////////////// // build some view-role ints // /////////////////////////////// @@ -339,7 +353,7 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest ////////////////////////////////////////// QContext.getQSession().setUser(new QUser().withIdReference(UUID.randomUUID().toString())); QContext.getQSession().setValue("roleIds", "1"); - assertThat(personalizer.getEffectiveTableViewForCurrentSession(tableWithoutDefaultView)) + assertThat(personalizer.getEffectiveTableViewForCurrentSession(tableWithDefaultView)) .isNotNull() .extracting("name").isEqualTo("a"); @@ -348,23 +362,20 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest //////////////////////////////////////////////////// QContext.getQSession().setUser(new QUser().withIdReference(UUID.randomUUID().toString())); QContext.getQSession().setValue("roleIds", "2"); - assertThat(personalizer.getEffectiveTableViewForCurrentSession(tableWithoutDefaultView)) + assertThat(personalizer.getEffectiveTableViewForCurrentSession(tableWithDefaultView)) .isNotNull() .extracting("name").isEqualTo("b"); ///////////////////////////////////////////////////////////////////////////// // find 2 views (and when they merge, their name is lost) through role int // + // do this last lookup twice, clearing memory record store // + // in between to demonstrate memoization being used. // ///////////////////////////////////////////////////////////////////////////// QContext.getQSession().setUser(new QUser().withIdReference(UUID.randomUUID().toString())); QContext.getQSession().setValue("roleIds", "2,3"); - - ///////////////////////////////////////////////////////////// - // do this last lookup twice, clearing memory record store // - // in between to demonstrate memoization being used. // - ///////////////////////////////////////////////////////////// for(int i = 0; i < 2; i++) { - TableView effectiveTableViewForCurrentSession = personalizer.getEffectiveTableViewForCurrentSession(tableWithoutDefaultView); + TableView effectiveTableViewForCurrentSession = personalizer.getEffectiveTableViewForCurrentSession(tableWithDefaultView); assertThat(effectiveTableViewForCurrentSession) .isNotNull() .extracting("name").isNull(); @@ -400,17 +411,20 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest CustomizableTableViewsTablePersonalizer personalizer = new CustomizableTableViewsTablePersonalizer(); QTableMetaData tableMetaData; + TableMetaDataInput tableActionInput = new TableMetaDataInput(); + /////////////////////////// // empty with empty case // /////////////////////////// tableMetaData = personalizer.applyViewToTable( new TableView(), - new QTableMetaData() - ); + new QTableMetaData(), + tableActionInput); assertThat(tableMetaData.getFields()).isNullOrEmpty(); assertThat(tableMetaData.getSections()).isNullOrEmpty(); QTableMetaData baseTable = new QTableMetaData() + .withName("baseTable") .withPrimaryKeyField("id") .withField(new QFieldMetaData("id", QFieldType.STRING).withIsEditable(false)) .withField(new QFieldMetaData("secret", QFieldType.STRING).withIsHidden(true)) @@ -426,7 +440,7 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest //////////////////////////////////////////////////////////////////////////////// tableMetaData = personalizer.applyViewToTable( new TableView().withFields(List.of()), - baseTable.clone()); + baseTable.clone(), tableActionInput); assertEquals(2, tableMetaData.getFields().size()); QFieldMetaDataAssert.assertThat(tableMetaData.getField("id")).isNotHidden().isNotEditable().isNotRequired(); @@ -444,12 +458,12 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// tableMetaData = personalizer.applyViewToTable( new TableView().withFields(List.of( - new TableViewField().withFieldName("x.id").withAccessLevel(EDITABLE_OPTIONAL), - new TableViewField().withFieldName("x.secret").withAccessLevel(EDITABLE_OPTIONAL), - new TableViewField().withFieldName("x.mandatory").withAccessLevel(EDITABLE_OPTIONAL), - new TableViewField().withFieldName("x.optional").withAccessLevel(EDITABLE_OPTIONAL) + new TableViewField().withFieldName("baseTable.id").withAccessLevel(EDITABLE_OPTIONAL), + new TableViewField().withFieldName("baseTable.secret").withAccessLevel(EDITABLE_OPTIONAL), + new TableViewField().withFieldName("baseTable.mandatory").withAccessLevel(EDITABLE_OPTIONAL), + new TableViewField().withFieldName("baseTable.optional").withAccessLevel(EDITABLE_OPTIONAL) )), - baseTable.clone()); + baseTable.clone(), tableActionInput); assertEquals(4, tableMetaData.getFields().size()); QFieldMetaDataAssert.assertThat(tableMetaData.getField("id")).isNotHidden().isNotEditable().isNotRequired(); @@ -478,7 +492,7 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest new TableView().withWidgets(List.of( new TableViewWidget().withWidgetName("lilWidgy").withAccessLevel(WidgetAccessLevel.HAS_ACCESS) )), - baseTable.clone()); + baseTable.clone(), tableActionInput); assertEquals(2, tableMetaData.getFields().size()); assertEquals(3, tableMetaData.getSections().size()); // one for id, one for mandatory, and 1 for our widget assertEquals(List.of("s0", "s1", "w0"), tableMetaData.getSections().stream().map(s -> s.getName()).toList()); @@ -486,6 +500,288 @@ 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()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDynamicDefaultValueFields() + { + /////////////////////////// + // empty with empty case // + /////////////////////////// + QTableMetaData baseTable = new QTableMetaData() + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.STRING).withIsEditable(false)) + .withField(new QFieldMetaData("secret", QFieldType.STRING).withIsHidden(true)) + .withField(new QFieldMetaData("mandatory", QFieldType.STRING).withIsRequired(true)) + .withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withBehavior(DynamicDefaultValueBehavior.CREATE_DATE)) + .withField(new QFieldMetaData("optional", QFieldType.STRING)) + .withSection(SectionFactory.defaultT1("id").withName("s0")) + .withSection(SectionFactory.defaultT2("secret", "mandatory").withName("s1")) + .withSection(SectionFactory.defaultT2("optional", "createDate").withName("s2")); + + //////////////////////////////////////////////////////////////////////// + // for actions that aren't insert or update, just get pkey & required // + //////////////////////////////////////////////////////////////////////// + for(AbstractTableActionInput actionInput : List.of(new QueryInput(), new DeleteInput(), new TableMetaDataInput())) + { + QTableMetaData tableMetaData = new CustomizableTableViewsTablePersonalizer().applyViewToTable( + new TableView().withFields(List.of()), + baseTable.clone(), actionInput); + + assertEquals(2, tableMetaData.getFields().size()); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("id")).isNotNull(); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("mandatory")).isNotNull(); + } + + ///////////////////////////////////////////////////// + // for insert or update action, get createDate too // + ///////////////////////////////////////////////////// + for(AbstractTableActionInput actionInput : List.of(new InsertInput(), new UpdateInput())) + { + QTableMetaData tableMetaData = new CustomizableTableViewsTablePersonalizer().applyViewToTable( + new TableView().withFields(List.of()), + baseTable.clone(), actionInput); + + assertEquals(3, tableMetaData.getFields().size()); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("id")).isNotNull(); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("mandatory")).isNotNull(); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("createDate")).isNotNull(); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFieldsInSecurityLocks() + { + /////////////////////////// + // empty with empty case // + /////////////////////////// + QTableMetaData baseTable = new QTableMetaData() + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.STRING).withIsEditable(false)) + .withField(new QFieldMetaData("a", QFieldType.STRING)) + .withField(new QFieldMetaData("b", QFieldType.STRING)) + .withField(new QFieldMetaData("c", QFieldType.STRING)) + .withField(new QFieldMetaData("d", QFieldType.STRING)); + + ////////////////////////////////////////////////////// + // with no security field, only id should come back // + ////////////////////////////////////////////////////// + { + QTableMetaData tableMetaData = new CustomizableTableViewsTablePersonalizer().applyViewToTable( + new TableView().withFields(List.of()), + baseTable.clone(), new QueryInput()); + + assertEquals(1, tableMetaData.getFields().size()); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("id")).isNotNull(); + } + + /////////////////////////////////////////////////////// + // with a simple security field, it should come back // + /////////////////////////////////////////////////////// + baseTable.setRecordSecurityLocks(List.of( + new RecordSecurityLock().withFieldName("a") + )); + { + QTableMetaData tableMetaData = new CustomizableTableViewsTablePersonalizer().applyViewToTable( + new TableView().withFields(List.of()), + baseTable.clone(), new QueryInput()); + + assertEquals(2, tableMetaData.getFields().size()); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("id")).isNotNull(); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("a")).isNotNull(); + } + + ///////////////////////////////////////////////////////////// + // with a multi-lock, all included fields should come back // + ///////////////////////////////////////////////////////////// + baseTable.setRecordSecurityLocks(List.of( + new MultiRecordSecurityLock() + .withOperator(MultiRecordSecurityLock.BooleanOperator.OR) + .withLocks(List.of( + new RecordSecurityLock().withFieldName("b"), + new MultiRecordSecurityLock() + .withOperator(MultiRecordSecurityLock.BooleanOperator.AND) + .withLocks(List.of( + new RecordSecurityLock().withFieldName("c"), + new RecordSecurityLock().withFieldName("d") + )) + )) + )); + { + QTableMetaData tableMetaData = new CustomizableTableViewsTablePersonalizer().applyViewToTable( + new TableView().withFields(List.of()), + baseTable.clone(), new QueryInput()); + + assertEquals(4, tableMetaData.getFields().size()); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("id")).isNotNull(); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("b")).isNotNull(); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("c")).isNotNull(); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("d")).isNotNull(); + } + + /////////////////////////////////////////////////////////// + // make sure lock field from join table doesn't break us // + /////////////////////////////////////////////////////////// + baseTable.setRecordSecurityLocks(List.of( + new RecordSecurityLock().withFieldName("join.a") + )); + { + QTableMetaData tableMetaData = new CustomizableTableViewsTablePersonalizer().applyViewToTable( + new TableView().withFields(List.of()), + baseTable.clone(), new QueryInput()); + + assertEquals(1, tableMetaData.getFields().size()); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("id")).isNotNull(); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFieldsWithSupplementalMetaData() + { + /////////////////////////// + // empty with empty case // + /////////////////////////// + QTableMetaData baseTable = new QTableMetaData() + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.STRING).withIsEditable(false)) + .withField(new QFieldMetaData("a", QFieldType.STRING)) + .withField(new QFieldMetaData("b", QFieldType.STRING)) + .withField(new QFieldMetaData("c", QFieldType.STRING)) + .withField(new QFieldMetaData("d", QFieldType.STRING)); + + ////////////////////////////////// + // base case, should just be id // + ////////////////////////////////// + { + QTableMetaData tableMetaData = new CustomizableTableViewsTablePersonalizer().applyViewToTable( + new TableView().withFields(List.of()), + baseTable.clone(), new QueryInput()); + + assertEquals(1, tableMetaData.getFields().size()); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("id")).isNotNull(); + } + + /////////////////////////////////////////////////////////////////////// + // supplemental meta data, but without a rule should not be included // + /////////////////////////////////////////////////////////////////////// + baseTable.getField("a").withSupplementalMetaData(new CustomizableTableViewsFieldMetaData()); + { + QTableMetaData tableMetaData = new CustomizableTableViewsTablePersonalizer().applyViewToTable( + new TableView().withFields(List.of()), + baseTable.clone(), new QueryInput()); + + assertEquals(1, tableMetaData.getFields().size()); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("id")).isNotNull(); + } + + ////////////////////////////////////////////////////////////////////// + // supplemental meta data, with the include rule should be included // + ////////////////////////////////////////////////////////////////////// + baseTable.getField("b").withSupplementalMetaData(new CustomizableTableViewsFieldMetaData() + .withRule(CustomizableTableViewsFieldMetaData.Rule.ALWAYS_KEEP_FIELD)); + { + QTableMetaData tableMetaData = new CustomizableTableViewsTablePersonalizer().applyViewToTable( + new TableView().withFields(List.of()), + baseTable.clone(), new QueryInput()); + + assertEquals(2, tableMetaData.getFields().size()); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("id")).isNotNull(); + QFieldMetaDataAssert.assertThat(tableMetaData.getField("b")).isNotNull(); + } + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -550,6 +846,11 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest new InsertAction().execute(new InsertInput(CustomizableTable.TABLE_NAME).withRecordEntities(List.of( new CustomizableTable().withTableName(baseTable.getName()).withIsActive(true)))); + //////////////////////////////////////////////////////////////// + // assert system inputSource still gets same as default table // + //////////////////////////////////////////////////////////////// + assertSame(baseTable, personalizer.execute(new TableMetaDataPersonalizerInput().withTableMetaData(baseTable).withInputSource(QInputSource.SYSTEM))); + /////////////////////////////////////////// // assert it's pretty empty with no view // /////////////////////////////////////////// @@ -571,6 +872,11 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest new InsertAction().execute(new InsertInput(TableViewRoleInt.TABLE_NAME).withRecordEntities(List.of( new TableViewRoleInt().withRoleId(101).withTableViewId(1)))); + //////////////////////////////////////////////////////////////// + // assert system inputSource still gets same as default table // + //////////////////////////////////////////////////////////////// + assertSame(baseTable, personalizer.execute(new TableMetaDataPersonalizerInput().withTableMetaData(baseTable).withInputSource(QInputSource.SYSTEM))); + QContext.getQSession().setUser(new QUser().withIdReference(UUID.randomUUID().toString())); QContext.getQSession().setValue("roleIds", "101"); @@ -583,6 +889,54 @@ class CustomizableTableViewsTablePersonalizerTest extends BaseTest assertEquals(Set.of("id", "optional"), personalizedTable.getFields().values().stream().map(s -> s.getName()).collect(Collectors.toSet())); assertEquals(3, personalizedTable.getSections().size()); assertEquals(List.of("s0", "s1", "w0"), personalizedTable.getSections().stream().map(s -> s.getName()).toList()); + + ////////////////////////////////////////////////////////////////////////// + // add a join table, and a field from it in the main table's t2 section // + ////////////////////////////////////////////////////////////////////////// + QTableMetaData joinTable = new QTableMetaData() + .withName("joinTable") + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.STRING).withIsEditable(false)) + .withField(new QFieldMetaData("joinField", QFieldType.STRING)) + .withSection(SectionFactory.defaultT1("id", "joinField").withName("s0")); + QContext.getQInstance().addTable(joinTable); + QContext.getQInstance().addJoin(new QJoinMetaData().withLeftTable("baseTable").withRightTable("joinTable").withInferredName().withType(JoinType.ONE_TO_ONE).withJoinOn(new JoinOn("id", "joinField"))); + baseTable.getSection("s1").getFieldNames().add("joinTable.joinField"); + + ////////////////////////////////////////// + // by default, should get the joinField // + ////////////////////////////////////////// + personalizedTable = personalizer.execute(input); + assertEquals(List.of("optional", "joinTable.joinField"), personalizedTable.getSection("s1").getFieldNames()); + + ////////////////////////////////////////////////////////////////////////////////// + // activate customization on the join table, which should remove the join field // + ////////////////////////////////////////////////////////////////////////////////// + new InsertAction().execute(new InsertInput(CustomizableTable.TABLE_NAME).withRecordEntities(List.of( + new CustomizableTable().withTableName(joinTable.getName()).withIsActive(true)))); + personalizedTable = personalizer.execute(input); + assertEquals(List.of("optional"), personalizedTable.getSection("s1").getFieldNames()); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // add a view to the join table, with a field, and attach it to the view - then the user gets the field back // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + new InsertAction().execute(new InsertInput(TableView.TABLE_NAME).withRecordEntities(List.of( + new TableView().withId(2).withCustomizableTableId(2).withName("a") + .withFields(List.of(new TableViewField().withFieldName(joinTable.getName() + ".joinField").withAccessLevel(EDITABLE_OPTIONAL)))))); + new InsertAction().execute(new InsertInput(TableViewRoleInt.TABLE_NAME).withRecordEntities(List.of( + new TableViewRoleInt().withRoleId(101).withTableViewId(2)))); + + personalizedTable = personalizer.execute(input); + assertEquals(List.of("optional", "joinTable.joinField"), personalizedTable.getSection("s1").getFieldNames()); + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // finally, set user to not have this role, make sure the section with the join field disappears // + // (the non-join field will have also gone away by switching to other role, so the section is empty) // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + QContext.getQSession().setUser(new QUser().withIdReference(UUID.randomUUID().toString())); + QContext.getQSession().setValue("roleIds", "102"); + personalizedTable = personalizer.execute(input); + assertNull(personalizedTable.getSection("s1")); } 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)); + } + + + /*************************************************************************** * ***************************************************************************/