diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java b/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java index 7ee070e..8593262 100644 --- a/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java +++ b/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java @@ -13,6 +13,9 @@ import com.kingsrook.qbits.crm.core.model.enums.CrmDirection; import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.email.model.EmailMessage; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; @@ -87,6 +90,12 @@ public class Activity extends QRecordEntity .withSection(SectionFactory.customT2("participants", new QIcon("group")).withWidgetName(participantChildJoinName)) .withSection(SectionFactory.defaultT3("externalCalendarId", "createDate", "modifyDate")); + ////////////////////////////////////////////////////////////////// + // Only offer outcomes belonging to the selected activity type. // + ////////////////////////////////////////////////////////////////// + table.getField("outcomeId").withPossibleValueSourceFilter(new QQueryFilter( + new QFilterCriteria("activityTypeId", QCriteriaOperator.EQUALS, "${input.activityTypeId}"))); + return (table); } } diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/EmailListMember.java b/src/main/java/com/kingsrook/qbits/crm/core/model/EmailListMember.java index acfe747..24d9aaf 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/EmailListMember.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/EmailListMember.java @@ -7,6 +7,9 @@ package com.kingsrook.qbits.crm.core.model; import java.time.Instant; import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; @@ -55,6 +58,12 @@ public class EmailListMember extends QRecordEntity .withSection(SectionFactory.defaultT2("subscribedDate", "unsubscribedDate", "subscriptionSource", "formSubmissionId")) .withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + //////////////////////////////////////////////////////////// + // Only offer submissions linked to the selected contact. // + //////////////////////////////////////////////////////////// + table.getField("formSubmissionId").withPossibleValueSourceFilter(new QQueryFilter( + new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, "${input.contactId}"))); + return (table); } } diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java b/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java index 85545ab..62df13b 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java @@ -14,6 +14,9 @@ import com.kingsrook.qbits.crm.core.model.Company; import com.kingsrook.qbits.crm.core.model.LeadSource; import com.kingsrook.qbits.crm.core.model.enums.CrmDealPriority; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; @@ -129,6 +132,12 @@ public class Deal extends QRecordEntity } } + /////////////////////////////////////////////////////////// + // Only offer stages belonging to the selected pipeline. // + /////////////////////////////////////////////////////////// + table.getField("pipelineStageId").withPossibleValueSourceFilter(new QQueryFilter( + new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, "${input.pipelineId}"))); + return (table); } } diff --git a/src/test/java/com/kingsrook/qbits/crm/ContingentPossibleValuesTest.java b/src/test/java/com/kingsrook/qbits/crm/ContingentPossibleValuesTest.java new file mode 100644 index 0000000..2cc35a7 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/ContingentPossibleValuesTest.java @@ -0,0 +1,122 @@ +/******************************************************************************* + ** Tests for CRM choices scoped by another editable field on the same form. + *******************************************************************************/ +package com.kingsrook.qbits.crm; + + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.crm.activities.model.Activity; +import com.kingsrook.qbits.crm.activities.model.ActivityOutcome; +import com.kingsrook.qbits.crm.activities.processes.LogActivityProcess; +import com.kingsrook.qbits.crm.core.model.EmailListMember; +import com.kingsrook.qbits.crm.core.model.FormSubmission; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.deals.model.PipelineStage; +import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput; +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.possiblevalues.PossibleValueSearchFilterUseCase; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Exercise the generated metadata and real possible-value searches in memory. + *******************************************************************************/ +class ContingentPossibleValuesTest extends BaseTest +{ + /******************************************************************************* + ** Every contingent field must point to an existing scope column and retain + ** its template after searches for either scope and either empty-input mode. + *******************************************************************************/ + @ParameterizedTest + @CsvSource({ + Deal.TABLE_NAME + ", pipelineId, pipelineStageId, " + PipelineStage.TABLE_NAME, + Activity.TABLE_NAME + ", activityTypeId, outcomeId, " + ActivityOutcome.TABLE_NAME, + EmailListMember.TABLE_NAME + ", contactId, formSubmissionId, " + FormSubmission.TABLE_NAME + }) + void testContingentSearch(String tableName, String parent, String child, String sourceTable) throws QException + { + QTableMetaData table = QContext.getQInstance().getTable(tableName); + QFieldMetaData field = table.getField(child); + assertThat(table.getField(parent).getIsEditable()).isTrue(); + assertThat(field.getIsEditable()).isTrue(); + assertThat(QContext.getQInstance().getPossibleValueSource(field.getPossibleValueSourceName()).getTableName()).isEqualTo(sourceTable); + assertThat(QContext.getQInstance().getTable(sourceTable).getField(parent)).isNotNull(); + + QQueryFilter template = field.getPossibleValueSourceFilter(); + assertThat(template).isNotNull(); + QFilterCriteria expected = new QFilterCriteria(parent, QCriteriaOperator.EQUALS, "${input." + parent + "}"); + assertThat(template.getCriteria()).containsExactly(expected); + + ////////////////////////////////////////////////////////////////////////////////// + // Seed the search backend directly so insert customizers cannot relink scopes. // + ////////////////////////////////////////////////////////////////////////////////// + MemoryRecordStore.getInstance().insert(new InsertInput(sourceTable).withRecords(List.of( + scopedRecord(11, parent, 1), scopedRecord(12, parent, 1), scopedRecord(21, parent, 2))), false); + + assertThat(search(field, Map.of(parent, 1), PossibleValueSearchFilterUseCase.FORM)).containsExactlyInAnyOrder(11, 12); + assertThat(search(field, Map.of(parent, 2), PossibleValueSearchFilterUseCase.FORM)).containsExactly(21); + assertThat(search(field, Map.of(), PossibleValueSearchFilterUseCase.FORM)).isEmpty(); + assertThat(search(field, Map.of(), PossibleValueSearchFilterUseCase.FILTER)).containsExactlyInAnyOrder(11, 12, 21); + assertThat(template.getCriteria()).containsExactly(expected); + } + + + + /******************************************************************************* + ** Optional company links and backend-only process inputs stay unrestricted. + *******************************************************************************/ + @Test + void testSkippedFields() + { + QTableMetaData activity = QContext.getQInstance().getTable(Activity.TABLE_NAME); + assertThat(activity.getField("companyId").getIsRequired()).isFalse(); + assertThat(activity.getField("contactId").getPossibleValueSourceFilter()).isNull(); + assertThat(activity.getField("dealId").getPossibleValueSourceFilter()).isNull(); + assertThat(QContext.getQInstance().getProcess(LogActivityProcess.NAME).getStepList()) + .allMatch(step -> step instanceof QBackendStepMetaData); + } + + + + /******************************************************************************* + ** Build a labeled row usable by each of the three table possible-value sources. + *******************************************************************************/ + private QRecord scopedRecord(int id, String parent, int scope) + { + return new QRecord().withValue("id", id).withValue(parent, scope) + .withValue("name", "Choice " + id).withValue("firstName", "Contact " + id) + .withValue("email", "contact" + id + "@example.com").withValue("formType", 1); + } + + + + /******************************************************************************* + ** Interpret a cloned field filter just as a frontend form or filter does. + *******************************************************************************/ + private List search(QFieldMetaData field, Map values, PossibleValueSearchFilterUseCase useCase) throws QException + { + QQueryFilter filter = field.getPossibleValueSourceFilter().clone(); + filter.interpretValues(MapBuilder.of("input", values), useCase); + return new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput() + .withPossibleValueSourceName(field.getPossibleValueSourceName()) + .withDefaultQueryFilter(filter)).getResults().stream().map(QPossibleValue::getId).toList(); + } +}