diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/Aggregate2DTableWidgetRendererTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/Aggregate2DTableWidgetRendererTest.java
new file mode 100644
index 00000000..d8c70032
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/Aggregate2DTableWidgetRendererTest.java
@@ -0,0 +1,96 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2024. 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.qqq.backend.core.actions.dashboard.widgets;
+
+
+import java.util.List;
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
+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.widgets.RenderWidgetInput;
+import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
+import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+/*******************************************************************************
+ ** Unit test for Aggregate2DTableWidgetRenderer
+ *******************************************************************************/
+class Aggregate2DTableWidgetRendererTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void test() throws QException
+ {
+ new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withRecords(List.of(
+ new QRecord().withValue("lastName", "Simpson").withValue("homeStateId", 50),
+ new QRecord().withValue("lastName", "Simpson").withValue("homeStateId", 50),
+ new QRecord().withValue("lastName", "Simpson").withValue("homeStateId", 50),
+ new QRecord().withValue("lastName", "Simpson").withValue("homeStateId", 49),
+ new QRecord().withValue("lastName", "Flanders").withValue("homeStateId", 49),
+ new QRecord().withValue("lastName", "Flanders").withValue("homeStateId", 49),
+ new QRecord().withValue("lastName", "Burns").withValue("homeStateId", 50)
+ )));
+
+ RenderWidgetInput input = new RenderWidgetInput();
+ input.setWidgetMetaData(new QWidgetMetaData()
+ .withDefaultValue("tableName", TestUtils.TABLE_NAME_PERSON_MEMORY)
+ .withDefaultValue("valueField", "id")
+ .withDefaultValue("rowField", "lastName")
+ .withDefaultValue("columnField", "homeStateId")
+ .withDefaultValue("orderBys", "row")
+ );
+ RenderWidgetOutput output = new Aggregate2DTableWidgetRenderer().render(input);
+ TableData tableData = (TableData) output.getWidgetData();
+ System.out.println(tableData.getRows());
+
+ TableDataAssert.assertThat(tableData)
+ .hasRowWithColumnContaining("_row", "Simpson", row ->
+ row.hasColumnContaining("50", "3")
+ .hasColumnContaining("49", "1")
+ .hasColumnContaining("_total", "4"))
+ .hasRowWithColumnContaining("_row", "Flanders", row ->
+ row.hasColumnContaining("50", "0")
+ .hasColumnContaining("49", "2")
+ .hasColumnContaining("_total", "2"))
+ .hasRowWithColumnContaining("_row", "Burns", row ->
+ row.hasColumnContaining("50", "1")
+ .hasColumnContaining("49", "0")
+ .hasColumnContaining("_total", "1"))
+ .hasRowWithColumnContaining("_row", "Total", row ->
+ row.hasColumnContaining("50", "4")
+ .hasColumnContaining("49", "3")
+ .hasColumnContaining("_total", "7"));
+
+ List rowLabels = tableData.getRows().stream().map(r -> r.get("_row").toString()).toList();
+ assertEquals(List.of("Burns", "Flanders", "Simpson", "Total"), rowLabels);
+ }
+
+}
\ No newline at end of file
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataAssert.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataAssert.java
new file mode 100644
index 00000000..5724f815
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataAssert.java
@@ -0,0 +1,215 @@
+/*
+ * Copyright © 2022-2023. ColdTrack . All Rights Reserved.
+ */
+
+package com.kingsrook.qqq.backend.core.actions.dashboard.widgets;
+
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
+import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
+import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import org.assertj.core.api.AbstractAssert;
+import org.assertj.core.api.Assertions;
+
+
+/*******************************************************************************
+ ** AssertJ assert class for widget TableData
+ *******************************************************************************/
+public class TableDataAssert extends AbstractAssert
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ protected TableDataAssert(TableData actual, Class> selfType)
+ {
+ super(actual, selfType);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static TableDataAssert assertThat(RenderWidgetOutput widgetOutput)
+ {
+ Assertions.assertThat(widgetOutput).isNotNull();
+ QWidgetData widgetData = widgetOutput.getWidgetData();
+ Assertions.assertThat(widgetData).isNotNull();
+ Assertions.assertThat(widgetData).isInstanceOf(TableData.class);
+ return (new TableDataAssert((TableData) widgetData, TableDataAssert.class));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static TableDataAssert assertThat(TableData actual)
+ {
+ return (new TableDataAssert(actual, TableDataAssert.class));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public TableDataAssert hasSize(int expectedSize)
+ {
+ Assertions.assertThat(actual.getRows()).hasSize(expectedSize);
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public TableDataAssert hasSizeAtLeast(int sizeAtLeast)
+ {
+ Assertions.assertThat(actual.getRows()).hasSizeGreaterThanOrEqualTo(sizeAtLeast);
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public TableDataAssert doesNotHaveRowWithColumnContaining(String columnName, String containingValue)
+ {
+ for(Map row : actual.getRows())
+ {
+ if(row.containsKey(columnName))
+ {
+ String value = String.valueOf(row.get(columnName));
+ if(value != null && value.contains(containingValue))
+ {
+ failWithMessage("Failed because a row was found with a value in the [" + columnName + "] column containing [" + containingValue + "]"
+ + (containingValue.equals(value) ? "" : " (full value: [" + value + "])."));
+ }
+ }
+ }
+
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public TableDataAssert hasRowWithColumnContaining(String columnName, String containingValue)
+ {
+ hasRowWithColumnContaining(columnName, containingValue, (row) ->
+ {
+ });
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public TableDataAssert hasRowWithColumnContaining(String columnName, String containingValue, Consumer rowAsserter)
+ {
+ return hasRowWithColumnPredicate(columnName, value -> value != null && value.contains(containingValue), "containing [" + containingValue + "]", rowAsserter);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public TableDataAssert hasRowWithColumnMatching(String columnName, String matchingValue)
+ {
+ hasRowWithColumnMatching(columnName, matchingValue, (row) ->
+ {
+ });
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public TableDataAssert hasRowWithColumnMatching(String columnName, String matchingValue, Consumer rowAsserter)
+ {
+ return hasRowWithColumnPredicate(columnName, value -> value != null && value.matches(matchingValue), "matching [" + matchingValue + "]", rowAsserter);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public TableDataAssert hasRowWithColumnEqualTo(String columnName, String equalToValue)
+ {
+ hasRowWithColumnEqualTo(columnName, equalToValue, (row) ->
+ {
+ });
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public TableDataAssert hasRowWithColumnEqualTo(String columnName, String equalToValue, Consumer rowAsserter)
+ {
+ return hasRowWithColumnPredicate(columnName, value -> Objects.equals(value, equalToValue), "equalTo [" + equalToValue + "]", rowAsserter);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private TableDataAssert hasRowWithColumnPredicate(String columnName, Predicate predicate, String predicateDescription, Consumer rowAsserter)
+ {
+ List foundValuesInColumn = new ArrayList<>();
+ for(Map row : actual.getRows())
+ {
+ if(row.containsKey(columnName))
+ {
+ String value = String.valueOf(row.get(columnName));
+ foundValuesInColumn.add(value);
+
+ if(predicate.test(value))
+ {
+ TableDataRowAssert tableDataRowAssert = TableDataRowAssert.assertThat(row);
+ rowAsserter.accept(tableDataRowAssert);
+
+ return (this);
+ }
+ }
+ }
+
+ if(actual.getRows().isEmpty())
+ {
+ failWithMessage("Failed because there are no rows in the table.");
+ }
+ else if(foundValuesInColumn.isEmpty())
+ {
+ failWithMessage("Failed to find any rows with a column named: [" + columnName + "]");
+ }
+ else
+ {
+ failWithMessage("Failed to find a row with column [" + columnName + "] " + predicateDescription
+ + ".\nFound values were:\n" + StringUtils.join("\n", foundValuesInColumn));
+ }
+ return (null);
+ }
+
+}
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataRowAssert.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataRowAssert.java
new file mode 100644
index 00000000..83ecf3ad
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataRowAssert.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright © 2022-2023. ColdTrack . All Rights Reserved.
+ */
+
+package com.kingsrook.qqq.backend.core.actions.dashboard.widgets;
+
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+import org.assertj.core.api.AbstractAssert;
+import org.assertj.core.api.Assertions;
+import static org.junit.jupiter.api.Assertions.fail;
+
+
+/*******************************************************************************
+ ** AssertJ assert class for a row of data from a widget TableData
+ *******************************************************************************/
+public class TableDataRowAssert extends AbstractAssert>
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ protected TableDataRowAssert(Map actual, Class> selfType)
+ {
+ super(actual, selfType);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static TableDataRowAssert assertThat(Map actual)
+ {
+ return (new TableDataRowAssert(actual, TableDataRowAssert.class));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public TableDataRowAssert hasColumnContaining(String columnName, String containingValue)
+ {
+ String value = String.valueOf(actual.get(columnName));
+ Assertions.assertThat(value)
+ .withFailMessage("Expected column [" + columnName + "] in row [" + actual + "] to contain [" + containingValue + "], but it didn't")
+ .contains(containingValue);
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public TableDataRowAssert hasNoSubRows()
+ {
+ Object subRowsObject = actual.get("subRows");
+ if(subRowsObject != null)
+ {
+ @SuppressWarnings("unchecked")
+ List