fix(ui): labels, money formatting, contact layout and home polish #14

Merged
KofTwentyTwo merged 2 commits from feature/ui-polish into develop 2026-10-14 20:42:34 +00:00
21 changed files with 607 additions and 56 deletions

No files matched your search

+2 -2
View File
@@ -179,8 +179,8 @@ CREATE TABLE IF NOT EXISTS crm_contact (
last_touch_referrer VARCHAR(1000),
last_touch_date TIMESTAMP WITHOUT TIME ZONE,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(320),
secondary_email VARCHAR(320),
phone VARCHAR(50),
@@ -0,0 +1,131 @@
/*
* 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/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** CRM presentation shared by package-discovered tables and child widgets.
*******************************************************************************/
public final class CrmPresentation
{
private static final Map<String, String> LABELS = Map.ofEntries(
Map.entry("crm_contact", "Contacts"),
Map.entry("crm_company", "Companies"),
Map.entry("crm_deal", "Deals"),
Map.entry("crm_activity", "Activities"),
Map.entry("crm_form_submission", "Web Leads"),
Map.entry("crm_email_list", "Lists"),
Map.entry("crm_pipeline", "Pipelines"),
Map.entry("crm_pipeline_stage", "Pipeline Stages"),
Map.entry("crm_product", "Products"),
Map.entry("crm_email_template", "Email Templates"),
Map.entry("crm_email_sequence", "Email Sequences"),
Map.entry("crm_sequence_step", "Sequence Steps"),
Map.entry("crm_sequence_enrollment", "Sequence Enrollments"),
Map.entry("crm_lead_score_rule", "Lead Score Rules"),
Map.entry("crm_assignment_rule", "Assignment Rules"),
Map.entry("crm_assignment_rule_member", "Assignment Rule Members"),
Map.entry("crm_sales_goal", "Sales Goals"),
Map.entry("crm_currency", "Currencies"),
Map.entry("crm_lifecycle_stage", "Lifecycle Stages"),
Map.entry("crm_lead_source", "Lead Sources"),
Map.entry("crm_industry", "Industries"),
Map.entry("crm_activity_type", "Activity Types"),
Map.entry("crm_activity_outcome", "Activity Outcomes"),
Map.entry("crm_contact_role", "Contact Roles"),
Map.entry("crm_win_loss_reason", "Win/Loss Reasons"),
Map.entry("crm_consent_record", "Consent Records"),
Map.entry("crm_email_list_member", "List Members"),
Map.entry("crm_email_message", "Email Messages"),
Map.entry("crm_deal_contact", "Deal Contacts"),
Map.entry("crm_deal_product", "Deal Products"),
Map.entry("crm_deal_stage_history", "Deal Stage History"),
Map.entry("crm_activity_participant", "Activity Participants"),
Map.entry("crm_external_mapping", "External Mappings"),
Map.entry("crm_job_state", "Job State"));
private static final Map<String, List<String>> CHILD_FIELDS = Map.ofEntries(
Map.entry("crm_contact", List.of("firstName", "lastName", "email", "phone")),
Map.entry("crm_deal", List.of("name", "pipelineStageId", "amount", "expectedCloseDate")),
Map.entry("crm_activity", List.of("subject", "activityTypeId", "startDate")),
Map.entry("crm_form_submission", List.of("formKey", "createDate", "conversionStatus")),
Map.entry("crm_email_list_member", List.of("emailListId", "status")),
Map.entry("crm_consent_record", List.of("consentType", "status", "consentDate")),
Map.entry("crm_sequence_enrollment", List.of("sequenceId", "status", "enrolledDate")),
Map.entry("crm_email_message", List.of("subject", "status", "sentDate")),
Map.entry("crm_deal_contact", List.of("contactId", "contactRoleId", "isPrimary")),
Map.entry("crm_deal_product", List.of("productId", "quantity", "unitPrice", "totalAmount")),
Map.entry("crm_deal_stage_history", List.of("fromStageId", "toStageId", "transitionDate", "userId")));
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmPresentation()
{
}
/*******************************************************************************
** Explicit labels avoid exposing technical table and user-id names.
*******************************************************************************/
public static void configureTable(QTableMetaData table)
{
table.setLabel(LABELS.get(table.getName()));
Map<String, String> users = Map.of("ownerUserId", "Owner", "assignedToUserId", "Assigned To",
"assignToUserId", "Assign To", "enrolledByUserId", "Enrolled By", "convertedByUserId", "Converted By", "userId", "User");
table.getFields().values().forEach(field ->
{
if(users.containsKey(field.getName()))
{
field.setLabel(users.get(field.getName()));
}
});
Map<String, List<String>> money = Map.of("crm_deal", List.of("amount", "amountInBaseCurrency", "weightedAmount"),
"crm_deal_product", List.of("unitPrice", "totalAmount"), "crm_product", List.of("unitPrice"), "crm_sales_goal", List.of("targetAmount"));
for(String field : money.getOrDefault(table.getName(), List.of()))
{
table.getField(field).setDisplayFormat(DisplayFormat.CURRENCY);
}
}
/*******************************************************************************
** RC.6 supports a per-widget field list, preserving each full child table.
*******************************************************************************/
public static void configureChildList(QWidgetMetaData widget, String childTable)
{
List<String> fields = CHILD_FIELDS.get(childTable);
if(fields != null)
{
widget.withDefaultValue(ChildRecordListRenderer.KEY_ONLY_INCLUDE_FIELD_NAMES, new ArrayList<>(fields));
}
}
}
@@ -123,13 +123,14 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
widget.setName(snakeCase(widget.getName()));
QCodeReference permissionChecker = widget.getPermissionRules() == null ? null : widget.getPermissionRules().getCustomPermissionChecker();
widget.setPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE).withCustomPermissionChecker(permissionChecker));
widget.setGridColumns(12);
widget.setGridColumns(com.kingsrook.qbits.crm.widgets.CrmHomeWidgets.TODAY.contains(widget.getName()) ? 4 : 12);
if(widget.getDefaultValues() != null && widget.getDefaultValues().get("joinName") instanceof String joinName)
{
String normalizedJoinName = snakeCase(joinName);
widget.withDefaultValue("joinName", normalizedJoinName);
QJoinMetaData join = metaDataProducerMultiOutput.getEach(QJoinMetaData.class).stream()
.filter(candidate -> candidate.getName().equals(normalizedJoinName)).findFirst().orElseThrow();
CrmPresentation.configureChildList(widget, join.getRightTable());
QTableMetaData parent = metaDataProducerMultiOutput.get(QTableMetaData.class, join.getLeftTable());
boolean alreadyAttached = parent.getSections() != null && parent.getSections().stream()
.anyMatch(section -> section.getWidgetName() != null && snakeCase(section.getWidgetName()).equals(widget.getName()));
@@ -141,8 +142,6 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
}
}
metaDataProducerMultiOutput.get(QTableMetaData.class, "crm_form_submission").setLabel("Web Leads");
metaDataProducerMultiOutput.get(QTableMetaData.class, "crm_email_list").setLabel("Lists");
metaDataProducerMultiOutput.get(QTableMetaData.class, Contact.TABLE_NAME).withSection(
new QFieldSection("crm_contact_timeline", "Timeline", new QIcon("history"), Tier.T2, List.of())
.withWidgetName("crm_contact_timeline"));
@@ -165,6 +164,11 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
for(QTableMetaData table : metaDataProducerMultiOutput.getEach(QTableMetaData.class))
{
String tableName = table.getName();
CrmPresentation.configureTable(table);
if(tableName.equals(Contact.TABLE_NAME))
{
addOrComposeCustomizer(table, TableCustomizers.POST_QUERY_RECORD, new QCodeReference(com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer.class));
}
if(tableName.equals(Deal.TABLE_NAME) || tableName.equals(Activity.TABLE_NAME))
{
addOrComposeCustomizer(table, TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(CrmRelationshipValidator.class));
@@ -27,7 +27,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Objects;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.CrmSessionUtils;
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
@@ -295,7 +294,7 @@ public class CrmIntakeApi
List<QRecord> stages = store.by("crm_lifecycle_stage", "name", "Lead");
contact = store.insert("crm_contact", new QRecord().withValue("email", normalize(request.email()))
.withValue("firstName", name(request.firstName())).withValue("lastName", name(request.lastName()))
.withValue("phone", request.phone()).withValue("ownerUserId", CrmSessionUtils.getCurrentUserId())
.withValue("phone", request.phone()).withValue("ownerUserId", null)
.withValue("leadSourceId", leadSource.getValueInteger("id")).withValue("lifecycleStageId", stages.isEmpty() ? null : stages.get(0).getValueInteger("id")));
}
else
@@ -396,6 +395,6 @@ public class CrmIntakeApi
*******************************************************************************/
private String name(String value)
{
return (value == null || value.isBlank() ? "Not provided" : value);
return (value == null || value.isBlank() ? null : value.strip());
}
}
@@ -0,0 +1,73 @@
/*
* 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/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.core.customizers;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
import com.kingsrook.qqq.backend.core.actions.permissions.FieldSecurityLockHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** RC.6 recordLabelFormat is printf-only; preserve a computed query/get label.
*******************************************************************************/
public class ContactLabelCustomizer extends AbstractPostQueryCustomizer
{
/*******************************************************************************
** Keep missing names absent in storage while giving records useful labels.
*******************************************************************************/
@Override
public List<QRecord> apply(List<QRecord> records)
{
for(QRecord record : records)
{
QRecord visible = new QRecord().withValue("id", record.getValue("id"));
for(String name : List.of("firstName", "lastName", "email"))
{
var field = QContext.getQInstance().getTable(Contact.TABLE_NAME).getField(name);
if(FieldSecurityLockHelper.isFieldAccessible(field)
&& !(input != null && input.getShouldOmitHiddenFields() && field.getIsHidden())
&& !(input != null && input.getShouldMaskPasswords() && field.getType().needsMasked()))
{
visible.setValue(name, record.getValue(name));
}
}
record.setRecordLabel(label(visible));
}
return (records);
}
/*******************************************************************************
** Prefer the supplied name, then email, then the record identifier.
*******************************************************************************/
public static String label(QRecord record)
{
String name = (Objects.toString(record.getValue("firstName"), "").strip() + " "
+ Objects.toString(record.getValue("lastName"), "").strip()).strip();
return (name.isBlank() ? Objects.toString(record.getValue("email"), Objects.toString(record.getValue("id"), "Contact")) : name);
}
}
@@ -101,26 +101,23 @@ public class Contact extends QRecordEntity
@Override
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
{
QFieldSection t1Section = SectionFactory.defaultT1("id", "firstName", "lastName", "email", "phone");
QFieldSection t1Section = SectionFactory.defaultT1("firstName", "lastName", "email", "phone");
table
.withIcon(new QIcon().withName("person"))
.withRecordLabelFormat("%s %s")
.withRecordLabelFields("firstName", "lastName")
.withRecordLabelFormat(null)
.withRecordLabelFields(List.of())
.withSection(t1Section)
.withSection(new QFieldSection("details", new QIcon().withName("info"), Tier.T2,
List.of("companyId", "jobTitle", "department", "lifecycleStageId", "leadSourceId",
"ownerUserId", "leadScore", "timezone", "preferredLanguage")))
.withSection(new QFieldSection("online", new QIcon().withName("language"), Tier.T2,
List.of("secondaryEmail", "mobilePhone", "website", "linkedinUrl")))
.withSection(new QFieldSection("address", new QIcon().withName("location_on"), Tier.T2,
List.of("addressLine1", "addressLine2", "city", "state", "postalCode", "country")))
.withSection(new QFieldSection("communication", new QIcon().withName("settings"), Tier.T2,
List.of("doNotEmail", "doNotCall", "unsubscribeDate")))
"ownerUserId", "leadScore", "timezone", "preferredLanguage", "description")))
.withSection(new QFieldSection("contactInformation", "Contact Information", new QIcon("contact_mail"), Tier.T2,
List.of("secondaryEmail", "mobilePhone", "website", "linkedinUrl", "addressLine1", "addressLine2", "city", "state", "postalCode", "country",
"doNotEmail", "doNotCall", "unsubscribeDate")))
.withSection(new QFieldSection("attribution", new QIcon().withName("campaign"), Tier.T2,
List.of("firstTouchUtmSource", "firstTouchUtmMedium", "firstTouchUtmCampaign", "firstTouchUtmContent", "firstTouchUtmTerm", "firstTouchLandingPage", "firstTouchReferrer", "firstTouchDate", "lastTouchUtmSource", "lastTouchUtmMedium", "lastTouchUtmCampaign", "lastTouchUtmContent", "lastTouchUtmTerm", "lastTouchLandingPage", "lastTouchReferrer", "lastTouchDate")).withLabel("Attribution"))
.withSection(SectionFactory.defaultT2("description"))
.withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate"));
.withSection(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3,
List.of("id", "lastActivityDate", "lastContactedDate", "createDate", "modifyDate")));
@@ -181,10 +178,10 @@ public class Contact extends QRecordEntity
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstName;
@QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastName;
@QField(maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -31,6 +31,7 @@ package com.kingsrook.qbits.crm.deals.customizers;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import com.kingsrook.qbits.crm.CrmSessionUtils;
import com.kingsrook.qbits.crm.deals.model.Deal;
@@ -130,6 +131,13 @@ public class DealInitializationCustomizer extends AbstractPostInsertCustomizer
}
}
if(stage != null && (Boolean.TRUE.equals(stage.getIsClosedWon()) || Boolean.TRUE.equals(stage.getIsClosedLost()))
&& record.getValue("actualCloseDate") == null)
{
updateRecord.setValue("actualCloseDate", now.atZone(ZoneOffset.UTC).toLocalDate());
needsUpdate = true;
}
///////////////////////////////////////////
// calculate weightedAmount //
///////////////////////////////////////////
@@ -22,19 +22,26 @@
package com.kingsrook.qbits.crm.widgets;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
@@ -70,9 +77,41 @@ public class CrmContactTimelineRenderer extends AbstractWidgetRenderer
String kind = Map.of("crm_activity", "Activity", "crm_email_message", "Email message",
"crm_form_submission", "Web lead", "crm_consent_record", "Consent record").get(table);
String detail = Objects.toString(record.getValue("subject"), Objects.toString(record.getValue("email"), kind));
rows.add(CrmHomeRenderer.row(AiLinkBuilder.recordHref(table, record.getValue("id")), record.getValue("createDate"), kind, detail));
rows.add(CrmHomeRenderer.row(AiLinkBuilder.recordHref(table, record.getValue("id")), formatDate(record.getValueInstant("createDate")), kind, detail));
}
}
return (new RenderWidgetOutput(CrmHomeRenderer.table(rows, "Recorded", "Kind", "Details")));
}
/*******************************************************************************
** Prefer the session timezone (including DST), then offset, otherwise UTC.
*******************************************************************************/
static String formatDate(Instant instant)
{
if(instant == null)
{
return ("");
}
ZoneId zone = ZoneOffset.UTC;
QSession session = QContext.getQSession();
String timezone = session == null ? null : session.getValue(QSession.VALUE_KEY_USER_TIMEZONE);
String offset = session == null ? null : session.getValue(QSession.VALUE_KEY_USER_TIMEZONE_OFFSET_MINUTES);
try
{
if(timezone != null && !timezone.isBlank())
{
zone = ZoneId.of(timezone);
}
else if(offset != null && !offset.isBlank())
{
zone = ZoneOffset.ofTotalSeconds(Math.multiplyExact(60, Integer.parseInt(offset)));
}
}
catch(DateTimeException | IllegalArgumentException | ArithmeticException e)
{
zone = ZoneOffset.UTC;
}
return (DateTimeFormatter.ofPattern("MMM d, uuuu h:mm a", Locale.US).withZone(zone).format(instant));
}
}
@@ -25,6 +25,8 @@ package com.kingsrook.qbits.crm.widgets;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
@@ -33,6 +35,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.crm.core.model.enums.CrmFormConversionStatus;
@@ -118,11 +121,20 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
/*******************************************************************************
** Preserve decimals and large counts in the standard statistics renderer.
** Format statistics with the configured base currency in the value.
*******************************************************************************/
private StatisticsData statistic(BigDecimal value, boolean currency)
private StatisticsData statistic(BigDecimal value, boolean currency) throws QException
{
return (new StatisticsData().withCount(value).withCountContext(currency ? "base currency" : null));
String prefix = "";
if(currency)
{
List<QRecord> base = query("crm_currency", equalsFilter("isBaseCurrency", true), "id", true, 1);
if(!base.isEmpty() && base.getFirst().getValueString("currencyCode") != null)
{
prefix = base.getFirst().getValueString("currencyCode") + " ";
}
}
return (new StatisticsData().withCount(prefix + formatAmount(value)));
}
@@ -197,7 +209,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
/*******************************************************************************
** Stage and deal count share one column, keeping both monetary columns.
** Separate the stage label, integer count and weighted base-currency amount.
*******************************************************************************/
private TableData pipeline(RenderWidgetInput input) throws QException
{
@@ -220,10 +232,10 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
{
BigDecimal[] values = totals.getOrDefault(stage.getValue("id"), new BigDecimal[] {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO});
String href = AiLinkBuilder.recordHref(STAGE, stage.getValue("id"));
rows.add(row(href, stage.getValueString("name") + " (" + values[0].toPlainString() + " deals)", values[1], values[2]));
rows.add(row(href, stage.getValueString("name"), values[0].toBigIntegerExact(), formatAmount(values[2])));
}
}
TableData data = table(rows, "Stage / deals", "Amount (base)", "Weighted amount (base)");
TableData data = table(rows, "Stage", "Deals", "Weighted amount");
if(pipelines.size() > 1)
{
data.withDropdownNameList(List.of("pipelineId")).withDropdownLabelList(List.of("Pipeline"))
@@ -246,7 +258,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
LocalDate start = today.withDayOfMonth(1).plusMonths(month);
QQueryFilter filter = period(dealStatus(false), "expectedCloseDate", start, start.plusMonths(1));
BigDecimal[] totals = totals(filter);
rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, filter), start.toString().substring(0, 7), totals[0], totals[2]));
rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, filter), start.toString().substring(0, 7), totals[0].toBigIntegerExact(), formatAmount(totals[2])));
}
return (table(rows, "Month", "Deals", "Weighted amount (base)"));
}
@@ -292,7 +304,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
for(QRecord lead : query("crm_form_submission", new QQueryFilter(), "createDate", false, 10))
{
String name = (Objects.toString(lead.getValue("firstName"), "") + " " + Objects.toString(lead.getValue("lastName"), "")).trim();
if(name.isBlank())
if(Objects.toString(lead.getValue("firstName"), "").isBlank() || Objects.toString(lead.getValue("lastName"), "").isBlank())
{
name = Objects.toString(lead.getValue("email"), "Web lead");
}
@@ -339,7 +351,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
BigDecimal target = CrmCurrencyUtils.convertToBaseCurrency(goal.getValueBigDecimal("targetAmount"), goal.getValueString("currencyCode"));
String attainment = target != null && target.signum() > 0 ? actual.multiply(BigDecimal.valueOf(100)).divide(target, 1, RoundingMode.HALF_UP) + "%" : "No target";
rows.add(row(AiLinkBuilder.recordHref("crm_sales_goal", goal.getValue("id")),
goal.getValueString("userId") + " (" + attainment + ")", target, actual));
goal.getValueString("userId") + " (" + attainment + ")", formatAmount(target), formatAmount(actual)));
}
TableData data = table(rows, "Owner / attainment", "Target (base)", "Won (base)");
return (data);
@@ -362,7 +374,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
Serializable user = result.getGroupByValue(owner);
QQueryFilter ownerFilter = filter.clone().withCriteria(new QFilterCriteria("ownerUserId", user == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, user));
rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, ownerFilter), Objects.toString(user, "Unassigned"),
number(result.getAggregateValue(COUNT)), number(result.getAggregateValue(AMOUNT))));
number(result.getAggregateValue(COUNT)).toBigIntegerExact(), formatAmount(number(result.getAggregateValue(AMOUNT)))));
}
return (table(rows, "Owner", "Won deals", "Won amount (base)"));
}
@@ -457,6 +469,18 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
}
/*******************************************************************************
** Group amounts without losing fractional precision or padding whole amounts.
*******************************************************************************/
static String formatAmount(BigDecimal value)
{
BigDecimal amount = value == null ? BigDecimal.ZERO : value;
DecimalFormat format = new DecimalFormat("#,##0", DecimalFormatSymbols.getInstance(Locale.US));
format.setMaximumFractionDigits(Math.max(0, amount.scale()));
return (format.format(amount));
}
/*******************************************************************************
** Every cell navigates to its record, or to the records in an aggregate.
*******************************************************************************/
@@ -54,7 +54,7 @@ public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput
{
output.add(widget(HOME.get(i), labels.get(i), i == 0 ? WidgetType.PARENT_WIDGET : WidgetType.TABLE));
}
List<String> statistics = List.of("Open pipeline (base currency)", "Won this month (base currency)", "New contacts (30 days)", "Web leads (7 days)", "My overdue activities");
List<String> statistics = List.of("Open pipeline", "Won this month", "New contacts (30 days)", "Web leads (7 days)", "My overdue activities");
for(int i = 0; i < TODAY.size(); i++)
{
output.add(widget(TODAY.get(i), statistics.get(i), WidgetType.STATISTICS));
@@ -688,3 +688,9 @@ CREATE TABLE IF NOT EXISTS crm_job_state (
last_success TIMESTAMP WITHOUT TIME ZONE,
last_full_pass TIMESTAMP WITHOUT TIME ZONE
);
--changeset qbit-crm:crm_nullable_intake_names_and_owners
ALTER TABLE crm_contact ALTER COLUMN first_name DROP NOT NULL;
ALTER TABLE crm_contact ALTER COLUMN last_name DROP NOT NULL;
ALTER TABLE crm_contact ALTER COLUMN owner_user_id DROP NOT NULL;
ALTER TABLE crm_deal ALTER COLUMN owner_user_id DROP NOT NULL;
+4 -4
View File
@@ -357,11 +357,11 @@ entries:
- key: table:crm_contact;field:firstName
summary: "The person's given name."
content: |
The person's given name. This is personal data and is removed by Erase Personal Data.
The person's given name, when supplied. Email-only contacts may leave names blank. This is personal data and is removed by Erase Personal Data.
- key: table:crm_contact;field:lastName
summary: "The person's family name."
content: |
The person's family name. This is personal data and is removed by Erase Personal Data.
The person's family name, when supplied. This is personal data and is removed by Erase Personal Data.
- key: table:crm_contact;field:email
summary: "Email address used to identify or contact the person."
content: |
@@ -593,11 +593,11 @@ entries:
- key: table:crm_form_submission;field:firstName
summary: "The person's given name."
content: |
The person's given name. This is personal data and is removed by Erase Personal Data.
The person's given name, when supplied. Email-only contacts may leave names blank. This is personal data and is removed by Erase Personal Data.
- key: table:crm_form_submission;field:lastName
summary: "The person's family name."
content: |
The person's family name. This is personal data and is removed by Erase Personal Data.
The person's family name, when supplied. This is personal data and is removed by Erase Personal Data.
- key: table:crm_form_submission;field:company
summary: "The company name as submitted on the form; review it before linking to an existing organization."
content: |
@@ -137,7 +137,7 @@ class CrmAppMetaDataTest extends BaseTest
/*******************************************************************************
** No retired trends; all widgets have explicit protection and full width.
** No retired trends; all widgets have explicit protection and statistics use compact widths.
*******************************************************************************/
@Test
void widgets()
@@ -147,7 +147,7 @@ class CrmAppMetaDataTest extends BaseTest
for(QWidgetMetaDataInterface widget : instance.getWidgets().values())
{
assertThat(widget.getPermissionRules()).isNotNull();
assertThat(widget.getGridColumns()).isEqualTo(12);
assertThat(widget.getGridColumns()).isEqualTo(CrmHomeWidgets.TODAY.contains(widget.getName()) ? 4 : 12);
assertThat(widget.getType()).isNotEqualTo(WidgetType.HTML.getType());
}
}
@@ -0,0 +1,138 @@
/*
* 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/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm;
import java.util.List;
import com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Presentation contracts remain stable across all discovered CRM metadata.
*******************************************************************************/
class CrmPresentationTest extends BaseTest
{
/*******************************************************************************
** Every table has an explicit human label and user identifiers read naturally.
*******************************************************************************/
@Test
void labelsAndMoney()
{
var instance = QContext.getQInstance();
for(var table : instance.getTables().values())
{
assertThat(table.getLabel()).isNotBlank().doesNotContain("_").isNotEqualToIgnoringCase(table.getName());
table.getFields().values().stream().filter(field -> field.getName().endsWith("UserId"))
.forEach(field -> assertThat(field.getLabel()).doesNotContain("User Id"));
}
assertThat(instance.getTable("crm_contact").getLabel()).isEqualTo("Contacts");
assertThat(instance.getTable("crm_deal").getField("amount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
assertThat(instance.getTable("crm_deal_product").getField("totalAmount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
assertThat(instance.getTable("crm_product").getField("unitPrice").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
assertThat(instance.getTable("crm_sales_goal").getField("targetAmount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
for(String table : List.of("crm_contact", "crm_deal"))
{
assertThat(instance.getTable(table).getField("ownerUserId").getIsRequired()).isFalse();
assertThat(instance.getTable(table).getField("ownerUserId").getLabel()).isEqualTo("Owner");
}
}
/*******************************************************************************
** All record-screen child lists have at most four valid, deliberate columns.
*******************************************************************************/
@Test
void sectionsAndChildLists()
{
var instance = QContext.getQInstance();
var contact = instance.getTable("crm_contact");
assertThat(contact.getSections()).extracting(section -> section.getName())
.contains("details", "contactInformation", "attribution", "system").doesNotContain("online", "address", "communication", "data", "dates");
assertThat(contact.getSections().stream().filter(section -> section.getName().equals("system")).findFirst().orElseThrow().getTier()).isEqualTo(Tier.T3);
for(String parent : List.of("crm_contact", "crm_company", "crm_deal"))
{
instance.getTable(parent).getSections().stream().filter(section -> section.getWidgetName() != null).forEach(section ->
{
var widget = instance.getWidget(section.getWidgetName());
if(widget.getDefaultValues() != null && widget.getDefaultValues().get("joinName") instanceof String joinName)
{
var child = instance.getTable(instance.getJoin(joinName).getRightTable());
List<?> fields = (List<?>) widget.getDefaultValues().get("onlyIncludeFieldNames");
assertThat(fields).isNotEmpty().hasSizeLessThanOrEqualTo(4);
fields.forEach(field -> assertThat(child.getFields()).containsKey(field.toString()));
}
});
}
}
/*******************************************************************************
** A computed label must not re-expose fields omitted by the record view.
*******************************************************************************/
@Test
void hiddenNamesStayOutOfLabels() throws Exception
{
QRecord inserted = new InsertAction().execute(new InsertInput("crm_contact").withRecord(new QRecord()
.withValue("firstName", "Private").withValue("lastName", "Name").withValue("email", "visible@example.test"))).getRecords().getFirst();
var table = QContext.getQInstance().getTable("crm_contact");
table.getField("firstName").setIsHidden(true);
table.getField("lastName").setIsHidden(true);
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput("crm_contact").withPrimaryKey(inserted.getValue("id"));
input.setShouldOmitHiddenFields(true);
QRecord visible = new GetAction().execute(input).getRecord();
assertThat(visible.getRecordLabel()).isEqualTo("visible@example.test");
assertThat(visible.getValue("firstName")).isNull();
}
/*******************************************************************************
** Missing names remain null while get and query return useful record labels.
*******************************************************************************/
@Test
void contactNameFallbacks() throws Exception
{
for(String[] values : new String[][] {{"Ada", "Lovelace", "Ada Lovelace"}, {"Ada", null, "Ada"}, {null, null, "ada@example.test"}})
{
QRecord inserted = new InsertAction().execute(new InsertInput("crm_contact").withRecord(new QRecord()
.withValue("firstName", values[0]).withValue("lastName", values[1]).withValue("email", "ada@example.test"))).getRecords().getFirst();
assertThat(inserted.getErrors()).isNullOrEmpty();
QRecord record = GetAction.execute("crm_contact", inserted.getValue("id"));
assertThat(record.getRecordLabel()).isEqualTo(values[2]);
assertThat(record.getValue("lastName")).isEqualTo(values[1]);
}
assertThat(new QueryAction().execute(new QueryInput("crm_contact")).getRecords()).extracting(QRecord::getRecordLabel)
.containsExactly("Ada Lovelace", "Ada", "ada@example.test");
assertThat(ContactLabelCustomizer.label(new QRecord().withValue("firstName", " ").withValue("lastName", " ").withValue("email", "mail@example.test")))
.isEqualTo("mail@example.test");
}
}
@@ -270,20 +270,41 @@ class CrmRdbmsTest
/*******************************************************************************
** Both install paths contain identical SQL, with one Liquibase changeset per table.
** Fresh DDL and the full upgrade changelog produce identical column definitions.
*******************************************************************************/
@Test
void testLiquibaseMatchesReferenceDdl() throws Exception
{
String ddl = Files.readString(Path.of("docs/ddl/postgres.sql"));
String changelog = Files.readString(Path.of("src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql"));
assertThat(changelog).startsWith("--liquibase formatted sql");
assertThat(changelog.lines().filter(line -> line.startsWith("--changeset qbit-crm:")).count()).isEqualTo(34);
assertThat(stripComments(changelog)).isEqualTo(stripComments(ddl));
assertThat(changelog.lines().filter(line -> line.startsWith("--changeset qbit-crm:")).count()).isEqualTo(35);
var freshColumns = schemaColumns();
executeSql("DROP ALL OBJECTS;");
executeSql(changelog);
assertThat(schemaColumns()).isEqualTo(freshColumns);
insert("crm_contact", new QRecord().withValue("email", "email-only@example.test"));
}
/*******************************************************************************
** Capture persisted types and nullability for both installation paths.
*******************************************************************************/
private java.util.Map<String, String> schemaColumns() throws Exception
{
java.util.Map<String, String> columns = new java.util.TreeMap<>();
try(ResultSet rows = connection.getMetaData().getColumns(null, "public", "crm_%", null))
{
while(rows.next())
{
columns.put(rows.getString("TABLE_NAME") + "." + rows.getString("COLUMN_NAME"),
rows.getString("TYPE_NAME") + ":" + rows.getInt("COLUMN_SIZE") + ":" + rows.getInt("NULLABLE"));
}
}
return (columns);
}
/*******************************************************************************
** Insert through QQQ and fail on record-level validation errors.
*******************************************************************************/
@@ -359,7 +380,7 @@ class CrmRdbmsTest
input.setWidgetMetaData(qInstance.getWidget("crm_pipeline_summary"));
TableData pipeline = (TableData) renderer.execute(input).getWidgetData();
assertThat(pipeline.getRows()).hasSize(2);
assertThat(pipeline.getRows().getFirst().get("first").toString()).endsWith(">Open (1 deals)</a>");
assertThat(pipeline.getRows().getFirst().get("first").toString()).endsWith(">Open</a>");
input.setWidgetMetaData(qInstance.getWidget("crm_deal_forecast"));
TableData forecast = (TableData) renderer.execute(input).getWidgetData();
assertThat(forecast.getRows()).hasSize(6);
@@ -386,7 +386,9 @@ class CrmActionsTest extends BaseTest
put("crm_contact", new QRecord().withValue("id", 10 + i * 2).withValue("lastName", "Contact").withValue("ownerUserId", "original"));
}
PageObserver.sizes.clear();
QContext.getQInstance().getTable("crm_contact").withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PageObserver.class));
QContext.getQInstance().getTable("crm_contact").getCustomizers().remove(TableCustomizers.POST_QUERY_RECORD.getRole());
QContext.getQInstance().getTable("crm_contact").withCustomizer(TableCustomizers.POST_QUERY_RECORD, com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer.of(
new QCodeReference(com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer.class), new QCodeReference(PageObserver.class)));
RunProcessOutput result = new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_calculate_lead_scores"));
assertThat(result.getValues().get("totalContacts")).isEqualTo(405);
assertThat(result.getValues().get("updatedCount")).isEqualTo(405);
@@ -99,6 +99,23 @@ public class CrmApisTest extends BaseTest
}
/*******************************************************************************
** Email-only intake preserves absent names and never assigns the service user.
*******************************************************************************/
@Test
void emailOnlyIntakeHasNoOwner() throws Exception
{
QContext.getQSession().setIdReference("site-api-system");
CrmFormResult result = intake.submitForm(new CrmFormRequest("contact", FormType.CONTACT, null, " ", "only@example.test",
null, null, "Hello", false, "Reply", null, null, null, null, null, null, null, null, null));
QRecord contact = CrmStore.asUser().get("crm_contact", result.contactId());
assertThat(contact.getValue("firstName")).isNull();
assertThat(contact.getValue("lastName")).isNull();
assertThat(contact.getValue("ownerUserId")).isNull();
assertThat(contact.getRecordLabel()).isEqualTo("only@example.test");
}
/*******************************************************************************
** Form preserves first touch and never grants implicit marketing consent.
*******************************************************************************/
@@ -530,7 +547,7 @@ public class CrmApisTest extends BaseTest
assertThat(CrmStore.trusted().by("crm_contact", "email", "subscriber@example.test").get(0).getValueString("ownerUserId")).isEqualTo("sales-0");
QContext.getQSession().setIdReference("named-owner");
Integer owned = intake.submitForm(form("owned@example.test", false, "owned")).contactId();
assertThat(CrmStore.trusted().get("crm_contact", owned).getValueString("ownerUserId")).isEqualTo("named-owner");
assertThat(CrmStore.trusted().get("crm_contact", owned).getValueString("ownerUserId")).isEqualTo("sales-1");
CrmStore.trusted().update("crm_contact", new QRecord().withValue("id", first.contactId()).withValue("ownerUserId", "system"));
intake.submitForm(form("first@example.test", false, "existing-unowned"));
assertThat(CrmStore.trusted().get("crm_contact", first.contactId()).getValueString("ownerUserId")).isEqualTo("system");
@@ -127,7 +127,7 @@ class ContactTest extends BaseTest
///////////////////////////////////
// verify T1 identity section //
///////////////////////////////////
assertThat(table.getSections().get(0).getFieldNames()).contains("id", "firstName", "lastName", "email", "phone");
assertThat(table.getSections().get(0).getFieldNames()).contains("firstName", "lastName", "email", "phone");
}
@@ -119,6 +119,30 @@ class DealInitializationCustomizerTest extends BaseTest
/*******************************************************************************
** Lost-stage inserts close immediately while imported dates are preserved.
*******************************************************************************/
@Test
void closedStageDates() throws QException
{
Integer pipeline = insertPipeline("Closed imports");
Integer stage = new InsertAction().execute(new InsertInput(PipelineStage.TABLE_NAME).withRecordEntity(new PipelineStage()
.withPipelineId(pipeline).withName("Lost").withSortOrder(1).withProbabilityPct(0).withIsClosedLost(true)))
.getRecords().getFirst().getValueInteger("id");
Deal lost = new Deal(GetAction.execute(Deal.TABLE_NAME, insertDeal("Lost", pipeline, stage, BigDecimal.TEN)));
assertEquals(java.time.LocalDate.now(java.time.ZoneOffset.UTC), lost.getActualCloseDate());
assertNotNull(lost.getStageEnteredDate());
java.time.LocalDate closeDate = java.time.LocalDate.of(2025, 1, 2);
java.time.Instant entered = java.time.Instant.parse("2025-01-01T10:00:00Z");
Integer imported = new InsertAction().execute(new InsertInput(Deal.TABLE_NAME).withRecordEntity(new Deal()
.withName("Imported").withPipelineId(pipeline).withPipelineStageId(stage).withActualCloseDate(closeDate).withStageEnteredDate(entered)))
.getRecords().getFirst().getValueInteger("id");
Deal saved = new Deal(GetAction.execute(Deal.TABLE_NAME, imported));
assertEquals(closeDate, saved.getActualCloseDate());
assertEquals(entered, saved.getStageEnteredDate());
}
/////////////////////////////////////////////////////////////////////////////
// Helper methods //
/////////////////////////////////////////////////////////////////////////////
@@ -64,6 +64,26 @@ class CrmContactTimelineWidgetTest extends BaseTest
}
/*******************************************************************************
** Session zones observe DST; missing or invalid zones fall back to UTC.
*******************************************************************************/
@Test
void readableDates()
{
Instant time = Instant.parse("2026-10-14T20:19:01.142263Z");
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 8:19 PM");
QContext.getQSession().setValue("UserTimezone", "America/Chicago");
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 3:19 PM");
assertThat(CrmContactTimelineRenderer.formatDate(Instant.parse("2026-01-14T20:19:00Z"))).isEqualTo("Jan 14, 2026 2:19 PM");
QContext.getQSession().setValue("UserTimezone", "invalid");
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 8:19 PM");
QContext.getQSession().setValue("UserTimezone", "");
QContext.getQSession().setValue("UserTimezoneOffsetMinutes", "120");
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 10:19 PM");
assertThat(CrmContactTimelineRenderer.formatDate(null)).isEmpty();
}
/*******************************************************************************
** Missing and nonexistent parent ids never query all contacts' histories.
*******************************************************************************/
@@ -82,6 +82,54 @@ class CrmHomeWidgetsTest extends BaseTest
}
/*******************************************************************************
** Whole and fractional amounts retain grouping without artificial decimals.
*******************************************************************************/
@Test
void amountFormatting()
{
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("72000.000000"))).isEqualTo("72,000");
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("1234.560000"))).isEqualTo("1,234.56");
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("-1234.005"))).isEqualTo("-1,234.005");
assertThat(CrmHomeRenderer.formatAmount(null)).isEqualTo("0");
}
/*******************************************************************************
** A direct won-stage insert participates in actual-close reporting immediately.
*******************************************************************************/
@Test
void directWonInsert() throws Exception
{
seedStages();
seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true));
var inserted = new com.kingsrook.qqq.backend.core.actions.tables.InsertAction().execute(new InsertInput("crm_deal")
.withRecord(new QRecord().withValue("name", "Direct win").withValue("pipelineId", 1).withValue("pipelineStageId", 2)
.withValue("amount", new BigDecimal("72000")).withValue("expectedCloseDate", LocalDate.now().plusYears(1))));
assertThat(inserted.getRecords().getFirst().getErrors()).isNullOrEmpty();
var deal = com.kingsrook.qqq.backend.core.actions.tables.GetAction.execute("crm_deal", inserted.getRecords().getFirst().getValue("id"));
assertThat(deal.getValue("ownerUserId")).isNull();
assertThat(deal.getValueLocalDate("actualCloseDate")).isEqualTo(LocalDate.now(ZoneOffset.UTC));
assertThat(deal.getValueInstant("stageEnteredDate")).isNotNull();
assertThat(((StatisticsData) render("crm_won_month")).getCount()).isEqualTo("USD 72,000");
TableData leaderboard = (TableData) render("crm_sales_leaderboard");
assertCell(leaderboard, 0, "first", "Unassigned");
assertCell(leaderboard, 0, "second", "1");
assertCell(leaderboard, 0, "third", "72,000");
}
/*******************************************************************************
** A partial submission name uses the email to retain identifying context.
*******************************************************************************/
@Test
void partialLeadName() throws Exception
{
seed("crm_form_submission", new QRecord().withValue("firstName", "Lee").withValue("email", "lee@example.test"));
assertCell((TableData) render("crm_recent_web_leads"), 0, "first", "lee@example.test");
}
/*******************************************************************************
** Empty data renders zero statistics, six months and no goals card.
*******************************************************************************/
@@ -119,16 +167,16 @@ class CrmHomeWidgetsTest extends BaseTest
TableData data = (TableData) render("crm_pipeline_summary");
assertThat(data.getColumns()).hasSize(3);
assertThat(data.getRows()).hasSize(3);
assertCell(data, 0, "first", "Open (2 deals)");
assertCell(data, 0, "second", "300.00");
assertCell(data, 0, "third", "40.50");
assertCell(data, 1, "first", "Won (0 deals)");
assertCell(data, 0, "first", "Open");
assertCell(data, 0, "second", "2");
assertCell(data, 0, "third", "40.5");
assertCell(data, 1, "first", "Won");
assertThat(data.getDropdownNameList()).containsExactly("pipelineId");
RenderWidgetInput selected = new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("crm_pipeline_summary"));
selected.addQueryParam("pipelineId", "2");
TableData other = (TableData) new CrmHomeRenderer().render(selected).getWidgetData();
assertThat(other.getRows()).hasSize(1);
assertCell(other, 0, "first", "Other (1 deals)");
assertCell(other, 0, "first", "Other");
selected.addQueryParam("pipelineId", "999");
assertThat(((TableData) new CrmHomeRenderer().render(selected).getWidgetData()).getRows()).hasSize(3);
assertLinks(data);
@@ -223,7 +271,7 @@ class CrmHomeWidgetsTest extends BaseTest
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_goals")).isFalse();
assertThat(data.getRows()).hasSize(1);
assertCell(data, 0, "first", TEST_USER_ID + " (50.0%)");
assertCell(data, 0, "second", "500.50");
assertCell(data, 0, "second", "500.5");
assertCell(data, 0, "third", "250.25");
assertLinks(data);
}
@@ -247,7 +295,7 @@ class CrmHomeWidgetsTest extends BaseTest
assertThat(data.getRows()).hasSize(2);
assertCell(data, 0, "first", "Ada");
assertCell(data, 0, "second", "2");
assertCell(data, 0, "third", "251.00");
assertCell(data, 0, "third", "251");
assertLinks(data);
}
@@ -331,7 +379,7 @@ class CrmHomeWidgetsTest extends BaseTest
*******************************************************************************/
private void assertStatistic(String name, String expected) throws QException
{
assertThat(new BigDecimal(((StatisticsData) render(name)).getCount().toString())).isEqualByComparingTo(expected);
assertThat(new BigDecimal(((StatisticsData) render(name)).getCount().toString().replace(",", ""))).isEqualByComparingTo(expected);
}