From 77cc2724259b7e9d3822278cb2a74adadda768ac Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 24 Feb 2025 11:07:22 -0600 Subject: [PATCH] Initial checkin --- .../widgets/RecordListWidgetRenderer.java | 251 ++++++++++++++++++ .../widgets/RecordListWidgetRendererTest.java | 188 +++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/RecordListWidgetRenderer.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/RecordListWidgetRendererTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/RecordListWidgetRenderer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/RecordListWidgetRenderer.java new file mode 100644 index 00000000..ffd6710e --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/RecordListWidgetRenderer.java @@ -0,0 +1,251 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. 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.net.URLEncoder; +import java.nio.charset.Charset; +import java.util.HashMap; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.instances.validation.plugins.QInstanceValidatorPluginInterface; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.FilterUseCase; +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.QueryOutput; +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.ChildRecordListData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.AbstractWidgetMetaDataBuilder; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Generic widget to display a list of records. + ** + ** Note, closely related to (and copied from ChildRecordListRenderer. + ** opportunity to share more code with that in the future?? + *******************************************************************************/ +public class RecordListWidgetRenderer extends AbstractWidgetRenderer +{ + private static final QLogger LOG = QLogger.getLogger(RecordListWidgetRenderer.class); + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static Builder widgetMetaDataBuilder(String widgetName) + { + return (new Builder(new QWidgetMetaData() + .withName(widgetName) + .withIsCard(true) + .withCodeReference(new QCodeReference(RecordListWidgetRenderer.class)) + .withType(WidgetType.CHILD_RECORD_LIST.getType()) + .withValidatorPlugin(new RecordListWidgetValidator()) + )); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static class Builder extends AbstractWidgetMetaDataBuilder + { + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public Builder(QWidgetMetaData widgetMetaData) + { + super(widgetMetaData); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public Builder withLabel(String label) + { + widgetMetaData.setLabel(label); + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public Builder withMaxRows(Integer maxRows) + { + widgetMetaData.withDefaultValue("maxRows", maxRows); + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public Builder withTableName(String tableName) + { + widgetMetaData.withDefaultValue("tableName", tableName); + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public Builder withFilter(QQueryFilter filter) + { + widgetMetaData.withDefaultValue("filter", filter); + return (this); + } + + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + try + { + Integer maxRows = null; + if(StringUtils.hasContent(input.getQueryParams().get("maxRows"))) + { + maxRows = ValueUtils.getValueAsInteger(input.getQueryParams().get("maxRows")); + } + else if(input.getWidgetMetaData().getDefaultValues().containsKey("maxRows")) + { + maxRows = ValueUtils.getValueAsInteger(input.getWidgetMetaData().getDefaultValues().get("maxRows")); + } + + QQueryFilter filter = ((QQueryFilter) input.getWidgetMetaData().getDefaultValues().get("filter")).clone(); + filter.interpretValues(new HashMap<>(input.getQueryParams()), FilterUseCase.DEFAULT); + filter.setLimit(maxRows); + + String tableName = ValueUtils.getValueAsString(input.getWidgetMetaData().getDefaultValues().get("tableName")); + QTableMetaData table = QContext.getQInstance().getTable(tableName); + + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(tableName); + queryInput.setShouldTranslatePossibleValues(true); + queryInput.setShouldGenerateDisplayValues(true); + queryInput.setFilter(filter); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + + QValueFormatter.setBlobValuesToDownloadUrls(table, queryOutput.getRecords()); + + int totalRows = queryOutput.getRecords().size(); + if(maxRows != null && (queryOutput.getRecords().size() == maxRows)) + { + ///////////////////////////////////////////////////////////////////////////////////// + // if the input said to only do some max, and the # of results we got is that max, // + // then do a count query, for displaying 1-n of // + ///////////////////////////////////////////////////////////////////////////////////// + CountInput countInput = new CountInput(); + countInput.setTableName(tableName); + countInput.setFilter(filter); + totalRows = new CountAction().execute(countInput).getCount(); + } + + String tablePath = QContext.getQInstance().getTablePath(tableName); + String viewAllLink = tablePath == null ? null : (tablePath + "?filter=" + URLEncoder.encode(JsonUtils.toJson(filter), Charset.defaultCharset())); + + ChildRecordListData widgetData = new ChildRecordListData(input.getQueryParams().get("widgetLabel"), queryOutput, table, tablePath, viewAllLink, totalRows); + + return (new RenderWidgetOutput(widgetData)); + } + catch(Exception e) + { + LOG.warn("Error rendering record list widget", e, logPair("widgetName", () -> input.getWidgetMetaData().getName())); + throw (e); + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static class RecordListWidgetValidator implements QInstanceValidatorPluginInterface + { + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public void validate(QWidgetMetaDataInterface widgetMetaData, QInstance qInstance, QInstanceValidator qInstanceValidator) + { + String prefix = "Widget " + widgetMetaData.getName() + ": "; + + ////////////////////////////////////////////// + // make sure table name is given and exists // + ////////////////////////////////////////////// + QTableMetaData table = null; + String tableName = ValueUtils.getValueAsString(CollectionUtils.nonNullMap(widgetMetaData.getDefaultValues()).get("tableName")); + if(qInstanceValidator.assertCondition(StringUtils.hasContent(tableName), prefix + "defaultValue for tableName must be given")) + { + //////////////////////////// + // make sure table exists // + //////////////////////////// + table = qInstance.getTable(tableName); + qInstanceValidator.assertCondition(table != null, prefix + "No table named " + tableName + " exists in the instance"); + } + + //////////////////////////////////////////////////////////////////////////////////// + // make sure filter is given and is valid (only check that if table is given too) // + //////////////////////////////////////////////////////////////////////////////////// + QQueryFilter filter = ((QQueryFilter) widgetMetaData.getDefaultValues().get("filter")); + if(qInstanceValidator.assertCondition(filter != null, prefix + "defaultValue for filter must be given") && table != null) + { + qInstanceValidator.validateQueryFilter(qInstance, prefix, table, filter, null); + } + } + + } +} diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/RecordListWidgetRendererTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/RecordListWidgetRendererTest.java new file mode 100644 index 00000000..64246384 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/RecordListWidgetRendererTest.java @@ -0,0 +1,188 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. 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.Map; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +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.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +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.ChildRecordListData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Unit test for RecordListWidgetRenderer + *******************************************************************************/ +class RecordListWidgetRendererTest extends BaseTest +{ + + /*************************************************************************** + ** + ***************************************************************************/ + private QWidgetMetaData defineWidget() + { + return RecordListWidgetRenderer.widgetMetaDataBuilder("testRecordListWidget") + .withTableName(TestUtils.TABLE_NAME_SHAPE) + .withMaxRows(20) + .withLabel("Some Shapes") + .withFilter(new QQueryFilter() + .withCriteria("id", QCriteriaOperator.LESS_THAN_OR_EQUALS, "${input.maxShapeId}") + .withCriteria("name", QCriteriaOperator.NOT_EQUALS, "Square") + .withOrderBy(new QFilterOrderBy("id", false)) + ).getWidgetMetaData(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testValidation() throws QInstanceValidationException + { + { + QInstance qInstance = TestUtils.defineInstance(); + QWidgetMetaData widgetMetaData = defineWidget(); + widgetMetaData.getDefaultValues().remove("tableName"); + qInstance.addWidget(widgetMetaData); + + assertThatThrownBy(() -> new QInstanceValidator().validate(qInstance)) + .isInstanceOf(QInstanceValidationException.class) + .hasMessageContaining("defaultValue for tableName must be given"); + } + + { + QInstance qInstance = TestUtils.defineInstance(); + QWidgetMetaData widgetMetaData = defineWidget(); + widgetMetaData.getDefaultValues().remove("filter"); + qInstance.addWidget(widgetMetaData); + + assertThatThrownBy(() -> new QInstanceValidator().validate(qInstance)) + .isInstanceOf(QInstanceValidationException.class) + .hasMessageContaining("defaultValue for filter must be given"); + } + + { + QInstance qInstance = TestUtils.defineInstance(); + QWidgetMetaData widgetMetaData = defineWidget(); + widgetMetaData.getDefaultValues().remove("tableName"); + widgetMetaData.getDefaultValues().remove("filter"); + qInstance.addWidget(widgetMetaData); + + assertThatThrownBy(() -> new QInstanceValidator().validate(qInstance)) + .isInstanceOf(QInstanceValidationException.class) + .hasMessageContaining("defaultValue for filter must be given") + .hasMessageContaining("defaultValue for tableName must be given"); + } + + { + QInstance qInstance = TestUtils.defineInstance(); + QWidgetMetaData widgetMetaData = defineWidget(); + QQueryFilter filter = (QQueryFilter) widgetMetaData.getDefaultValues().get("filter"); + filter.addCriteria(new QFilterCriteria("noField", QCriteriaOperator.EQUALS, "noValue")); + qInstance.addWidget(widgetMetaData); + + assertThatThrownBy(() -> new QInstanceValidator().validate(qInstance)) + .isInstanceOf(QInstanceValidationException.class) + .hasMessageContaining("Criteria fieldName noField is not a field in this table"); + } + + { + QInstance qInstance = TestUtils.defineInstance(); + QWidgetMetaData widgetMetaData = defineWidget(); + qInstance.addWidget(widgetMetaData); + + ////////////////////////////////// + // make sure valid setup passes // + ////////////////////////////////// + new QInstanceValidator().validate(qInstance); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testRender() throws QException + { + QInstance qInstance = TestUtils.defineInstance(); + QWidgetMetaData widgetMetaData = defineWidget(); + qInstance.addWidget(widgetMetaData); + + TestUtils.insertDefaultShapes(qInstance); + TestUtils.insertExtraShapes(qInstance); + + { + RecordListWidgetRenderer recordListWidgetRenderer = new RecordListWidgetRenderer(); + RenderWidgetInput input = new RenderWidgetInput(); + input.setWidgetMetaData(widgetMetaData); + input.setQueryParams(Map.of("maxShapeId", "1")); + RenderWidgetOutput output = recordListWidgetRenderer.render(input); + + ChildRecordListData widgetData = (ChildRecordListData) output.getWidgetData(); + assertEquals(1, widgetData.getTotalRows()); + assertEquals(1, widgetData.getQueryOutput().getRecords().get(0).getValue("id")); + assertEquals("Triangle", widgetData.getQueryOutput().getRecords().get(0).getValue("name")); + } + + { + RecordListWidgetRenderer recordListWidgetRenderer = new RecordListWidgetRenderer(); + RenderWidgetInput input = new RenderWidgetInput(); + input.setWidgetMetaData(widgetMetaData); + input.setQueryParams(Map.of("maxShapeId", "4")); + RenderWidgetOutput output = recordListWidgetRenderer.render(input); + + ChildRecordListData widgetData = (ChildRecordListData) output.getWidgetData(); + assertEquals(3, widgetData.getTotalRows()); + + ///////////////////////////////////////////////////////////////////////// + // id=2,name=Square was skipped due to NOT_EQUALS Square in the filter // + // max-shape-id applied we don't get id=5 or 6 // + // and they're ordered as specified in the filter (id desc) // + ///////////////////////////////////////////////////////////////////////// + assertEquals(4, widgetData.getQueryOutput().getRecords().get(0).getValue("id")); + assertEquals("Rectangle", widgetData.getQueryOutput().getRecords().get(0).getValue("name")); + + assertEquals(3, widgetData.getQueryOutput().getRecords().get(1).getValue("id")); + assertEquals("Circle", widgetData.getQueryOutput().getRecords().get(1).getValue("name")); + + assertEquals(1, widgetData.getQueryOutput().getRecords().get(2).getValue("id")); + assertEquals("Triangle", widgetData.getQueryOutput().getRecords().get(2).getValue("name")); + } + } + +} \ No newline at end of file