Merge pull request #6 from QRun-IO/feature/contingent-possible-values

feat: contingent possible values for deal stage, activity outcome and list-member submission
This commit was merged in pull request #6.
This commit is contained in:
james.maes authored and GitHub committed 2026-10-14 07:09:09 -05:00
commit 547c923bcc
4 files changed
+149

No files matched your search

@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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<Object> search(QFieldMetaData field, Map<String, Serializable> 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().<Object>map(QPossibleValue::getId).toList();
}
}