From 55b4e2154c15ec5a5fd50e5e4e5cfbba54ddf758 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 22 Feb 2024 11:32:20 -0600 Subject: [PATCH] Docs update - add RecordLookupHelper, RenderingWidgets, some content in Widgets, and "Supplemental Meta Data" in Tables [skip ci] --- docs/metaData/Tables.adoc | 38 ++++ docs/metaData/Widgets.adoc | 42 +++- docs/misc/RenderingWidgets.adoc | 224 ++++++++++++++++++++ docs/utilities/RecordLookupHelper.adoc | 272 +++++++++++++++++++++++++ 4 files changed, 572 insertions(+), 4 deletions(-) create mode 100644 docs/misc/RenderingWidgets.adoc create mode 100644 docs/utilities/RecordLookupHelper.adoc diff --git a/docs/metaData/Tables.adoc b/docs/metaData/Tables.adoc index 59cca275..2268ce50 100644 --- a/docs/metaData/Tables.adoc +++ b/docs/metaData/Tables.adoc @@ -244,3 +244,41 @@ QQQ provides the mechanism for UI's to present and manage such scripts (e.g., th * `scriptTypeId` - *Serializable (typically Integer), Required* - primary key value from the `"scriptType"` table in the instance, to designate the type of the Script. * `scriptTester` - *QCodeReference* - reference to a class which implements `TestScriptActionInterface`, that can be used by UI's for running an associated script to test it. + + +=== Supplemental Meta Data +==== QQQ Frontend Material Dashboard +When running a QQQ application with the QQQ Frontend Material Dashboard module (QFMD), +there are various pieces of supplemental meta-data which can be assigned to a Table, +to modify some behaviors for the table in this UI. + +===== Default Quick Filter Field Names +QFMD's table query has a "Basic" mode, which will always display a subset of the table's fields as quick-access filters. +By default, the "Tier 1" fields on a table (e.g., fields in a Section that is marked as T1) will be used for this purpose. + +However, you can customize which fields are shown as the default quick-filter fields, by providing a list of field names in a +`MaterialDashboardTableMetaData` object, placed in the table's `supplementalMetaData`. + +[source,java] +---- +table.withSupplementalMetaData(new MaterialDashboardTableMetaData() + .withDefaultQuickFilterFieldNames(List.of("id", "warehouseId", "statusId", "orderDate"))); +---- + +===== Go To Field Names +QFMD has a feature where a table's query screen can include a "Go To" button, +which a user can hit to open a modal popup, into which the user can enter a record's identifier, +to be brought directly to the record matching that identifier. + +To use this feature, the table must have a List of `GotoFieldNames` set in its +`MaterialDashboardTableMetaData` object in the table's `supplementalMetaData`. + +Each entry in this list is actually a list of fields, e.g., to account for a multi-value unique-key. + +[source,java] +---- +table.withSupplementalMetaData(new MaterialDashboardTableMetaData() + .withGotoFieldNames(List.of( + List.of("id"), + List.of("partnerName", "partnerOrderId")))); +---- diff --git a/docs/metaData/Widgets.adoc b/docs/metaData/Widgets.adoc index 67cbcc05..ae793068 100644 --- a/docs/metaData/Widgets.adoc +++ b/docs/metaData/Widgets.adoc @@ -2,16 +2,50 @@ == Widgets include::../variables.adoc[] -#TODO# +Widgets are the most customizable UI components in QQQ. +They can be used either on App Home Screens (e.g., as Dashboard screens), +or they can be included into Record View screens. + +QQQ defines several types of widgets, such as charts (pie, bar, line), +numeric displays, application-populated tables, or even fully custom HTML. === QWidgetMetaData A Widget is defined in a QQQ Instance in a `*QWidgetMetaData*` object. -#TODO# - *QWidgetMetaData Properties:* * `name` - *String, Required* - Unique name for the widget within the QQQ Instance. +* `type` - *String, Required* - Specifies the UI & data type for the widget. +* `label` - *String* - User-facing header or title for a widget. +* `tooltip` - *String* - Text contents to be placed in a tooltip associated with the widget's label in the UI. +** Values should come from the `WidgetType` enum's `getType()` method (e.g., `WidgetType.BAR_CHART.getType()`) +* `gridColumns` - *Integer* - for a desktop-sized screen, in a 12-based grid, +how many columns the widget should consume. +* `codeReference` - *QCodeReference, Required* - Reference to the custom code, +a subclass of `AbstractWidgetRenderer`, which is responsible for loading data to render the widget. +* `footerHTML` - *String* - HTML String, which, if present, will be displayed in the +footer of the widget (not supported by all widget types). +* `isCard` - *boolean, default false* #TODO# +* `showReloadButton` - *boolean, default true* #TODO# +* `showExportButton` - *boolean, default false* #TODO# +* `dropdowns` - #TODO# +* `storeDropdownSelections` - *boolean* #TODO# +* `icons` - *Map* #TODO# +* `defaultValues` - *Map* #TODO# -#TODO# +There are also some subclasses of `QWidgetMetaData`, for some specific widget types: + +*ParentWidgetMetaData Properties:* + +* `title` - *String* #TODO - how does this differ from label?# +* `childWidgetNameList` - *List, Required* +* `childProcessNameList` - *List* #TODO appears unused - check, and delete# +* `laytoutType` - *enum of GRID or TABS, default GRID* + +*QNoCodeWidgetMetaData Properties:* + +* `values` - *List* #TODO# +* `outputs` - *List* #TODO# + +#TODO - Examples# diff --git a/docs/misc/RenderingWidgets.adoc b/docs/misc/RenderingWidgets.adoc new file mode 100644 index 00000000..0a5386b2 --- /dev/null +++ b/docs/misc/RenderingWidgets.adoc @@ -0,0 +1,224 @@ +== Rendering Widgets +include::../variables.adoc[] + +=== WidgetRenderer classes +In general, to fully implement a Widget, you must define its `QWidgetMetaData`, +and supply a subclass of `AbstractWidgetRenderer`, to provide the data to the widget. +(Note the "No Code" category of widgets, which are an exception to this generalization). + +The only method required in a subclass of `AbstractWidgetRenderer` is: + + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + +The fields available in `RenderWidgetInput` are: + +- `Map queryParams` - These are parameters supplied by the frontend, for example, +if a user selected values from dropdowns to control a dimension of your widget, those name/value +pairs would be in this map. Similarly, if your widget is being included on a record view screen, then +the record's primary key will be in this map. +- `QWidgetMetaDataInterface widgetMetaData` - This is the meta-data for the widget being +rendered. This can be useful in case you are using the same renderer class for multiple widgets. + +The only field in `RenderWidgetOutput` is: + +- `QWidgetData widgetData` - This is a base class, with several attributes, and more importantly, +several subclasses, specific to the type of widget that is being rendered. + +==== Widget-Type Specific Rendering Details +Different widget types expect & require different types of values to be set in the `RenderWidgetOutput` by their renderers. + +===== Pie Chart +The `WidgetType.PIE_CHART` requires an object of type `ChartData`. +The fields on this type are: + +* `chartData` an instance of `ChartData.Data`, which has the following fields: +** `labels` - *List, required* - the labels for the slices of the pie. +** `datasets` - *List required* - the data for each slice of the pie. +For a Pie chart, only a single entry in this list is used +(other chart types using `ChartData` may support more than 1 entry in this list). +Fields in this object are: +*** `label` - *String, required* - a label to describe the dataset as a whole. +e.g., "Orders" for a pie showing orders of different statuses. +*** `data` - *List, required* - the data points for each slice of the pie. +*** `color` - *String* - HTML color for the slice +*** `urls` - *List* - Optional URLs for slices of the pie to link to when clicked. +*** `backgroundColors` - *List* - Optional HTML color codes for each slice of the pie. + +[source,java] +.Pie chart widget example +---- +// meta data +new QWidgetMetaData() + .withName("pieChartExample") + .withType(WidgetType.PIE_CHART.getType()) + .withGridColumns(4) + .withIsCard(true) + .withLabel("Pie Chart Example") + .withCodeReference(new QCodeReference(PieChartExampleRenderer.class)); + +// renderer +private List labels = new ArrayList<>(); +private List colors = new ArrayList<>(); +private List data = new ArrayList<>(); + +/******************************************************************************* +** helper method - to add values for a slice to the lists +*******************************************************************************/ +private void addSlice(String label, String color, Number datum) +{ + labels.add(label); + colors.add(color); + data.add(datum); +} + +/******************************************************************************* +** main method of the widget renderer +*******************************************************************************/ +@Override +public RenderWidgetOutput render(RenderWidgetInput input) throws QException +{ + addSlice("Apple", "#FF0000", 100); + addSlice("Orange", "#FF8000", 150); + addSlice("Banana", "#FFFF00", 75); + addSlice("Lime", "#00FF00", 100); + addSlice("Blueberry", "#0000FF", 200); + + ChartData chartData = new ChartData() + .withChartData(new ChartData.Data() + .withLabels(labels) + .withDatasets(List.of( + new ChartData.Data.Dataset() + .withLabel("Flavor") + .withData(data) + .withBackgroundColors(colors) + .withUrls(urls)))); + + return (new RenderWidgetOutput(chartData)); +} + +---- + +===== Bar Chart +#todo# + +===== Stacked Bar Chart +#todo# + +===== Horizontal Bar Chart +#todo# + +===== Child Record List +#todo# + +===== Line Chart +#todo# + +===== Small Line Chart +#todo# + +===== Statistics +#todo# + +===== Parent Widget +#todo# + +===== Composite +A `WidgetType.COMPOSITE` is built by using one or more smaller elements, known as `Blocks`. +Note that `Blocks` can also be used in the data of some other widget types +(specifically, within a cell of a Table-type widget, or (in the future?) as a header above a pie or bar chart). + +A composite widget renderer must return data of type `CompositeWidgetData`, +which has the following fields: + +* `blocks` - *List, required* - The blocks (1 or more) being composited together to make the widget. +See below for details on the specific Block types. +* `styleOverrides` - *Map* - Optional map of CSS attributes +(named following javascriptStyleCamelCase) to apply to the `
` element that wraps the rendered blocks. +* `layout` - *Layout enum* - Optional specifier for how the blocks should be laid out. +e.g., predefined sets of CSS attributes to achieve specific layouts. +** Note that some blocks are designed to work within composites with specific layouts. +Look for matching names, such as `Layout.BADGES_WRAPPER` to go with `NumberIconBadgeBlock`. + +[source,java] +.Composite widget example - consisting of 3 Progress Bar Blocks, and one Divider Block +---- +// meta data +new QWidgetMetaData() + .withName("compositeExample") + .withType(WidgetType.COMPOSITE.getType()) + .withGridColumns(4) + .withIsCard(true) + .withLabel("Composite Example") + .withCodeReference(new QCodeReference(CompositeExampleRenderer.class)); + +// renderer +public RenderWidgetOutput render(RenderWidgetInput input) throws QException +{ + CompositeWidgetData data = new CompositeWidgetData(); + + data.addBlock(new ProgressBarBlockData() + .withValues(new ProgressBarValues() + .withHeading("Blocks") + .withPercent(new BigDecimal("78.5")))); + + data.addBlock(new ProgressBarBlockData() + .withValues(new ProgressBarValues() + .withHeading("Progress") + .withPercent(new BigDecimal(0)))); + + data.addBlock(new DividerBlockData()); + + data.addBlock(new ProgressBarBlockData() + .withStyles(new ProgressBarStyles().withBarColor("#C0C000")) + .withValues(new ProgressBarValues() + .withHeading("Custom Color") + .withPercent(new BigDecimal("75.3")))); + + return (new RenderWidgetOutput(data)); +} +---- + + +===== Table +#todo# + +===== HTML +#todo# + +===== Divider +#todo# + +===== Process +#todo# + +===== Stepper +#todo# + +===== Data Bag Viewer +#todo# + +===== Script Viewer +#todo# + +=== Block-type Specific Rendering Details +For Composite-type widgets (or other widgets which can include blocks), +there are specific data classes required to be returned by the widget renderer. + +Each block type defines a subclass of `AbstractBlockWidgetData`, +which is a generic class with 3 type parameters: + +* `V` - an implementation of `BlockValuesInterface` - to define the type of values that the block uses. +* `S` - an implementation of `BlockSlotsInterface` (expected to be an `enum`) - to define the "slots" in the block, +that can have Tooltips and/or Links applied to them. +* `SX` - an implementation of `BlockStylesInterface` - to define the types of style customizations that the block supports. + +These type parameters are designed to ensure type-safety for the application developer, +to ensure that only + +=== Additional Tips + +* To make a Dashboard page (e.g., an App consisting of Widgets) with a parent widget use the parent widget's label as the page's label: +** On the `QAppMetaData` that contains the Parent widget, call +`.withSupplementalMetaData(new MaterialDashboardAppMetaData().withShowAppLabelOnHomeScreen(false))`. +** In the Parent widget's renderer, on the `ParentWidgetData`, call `setLabel("My Label")` and +`setIsLabelPageTitle(true)`. diff --git a/docs/utilities/RecordLookupHelper.adoc b/docs/utilities/RecordLookupHelper.adoc new file mode 100644 index 00000000..5d256c4d --- /dev/null +++ b/docs/utilities/RecordLookupHelper.adoc @@ -0,0 +1,272 @@ +== RecordLookupHelper +include::../variables.adoc[] + +`RecordLookupHelper` is a utility class that exists to help you... lookup records :) + +OK, I'll try to give a little more context: + +=== Motivation 1: Performance +One of the most significant performance optimizations that the team behind QQQ has found time and time again, +is to minimize the number of times you have to perform an I/O operation. +To just say it more plainly: +Make fewer calls to your database (or other backend). + +This is part of why the DML actions in QQQ (InsertAction, UpdateAction, DeleteAction) are all written to work on multiple records: +If you've got to insert 1,000 records, the performance difference between doing that as 1,000 SQL INSERT statements vs. just 1 statement cannot be overstated. + +Similarly then, for looking up records: +If we can do 1 round-trip to the database backend - that is - 1 query to fetch _n_ records, +then in almost all cases it will be significantly faster than doing _n_ queries, one-by-one, for those _n_ records. + +The primary reason why `RecordLookupHelper` exists is to help you cut down on the number of times you have to make a round-trip to a backend data store to fetch records within a process. + +[sidebar] +This basically is version of caching, isn't it? +Take a set of data from "far away" (e.g., database), and bring it "closer" (local or instance variables), for faster access. +So we may describe this as a "cache" through the rest of this document. + +=== Motivation 2: Convenience + +So, given that one wants to try to minimize the number of queries being executed to look up data in a QQQ processes, +one can certainly do this "by-hand" in each process that they write. + +Doing this kind of record caching in a QQQ Process `BackendStep` may be done as: + +* Adding a `Map` as a field in your class. +* Setting up and running a `QueryAction`, with a filter based on the collection of the keys you need to look up, then iterating over (or streaming) the results into the map field. +* Getting values out of the map when you need to use them (dealing with missing values as needed). + +That's not so bad, but, it does get a little verbose, especially if you're going to have several such caches in your class. + +As such, the second reason that `RecordLookupHelper` exists, is to be a reusable and convenient way to do this kind of optimization, +by providing methods to perform the bulk query & map building operation described above, +while also providing some convenient methods for accessing such data after it's been fetched. +In addition, a single instance of `RecordLookupHelper` can provide this service for multiple tables at once +(e.g., so you don't need to add a field to your class for each type of data that you're trying to cache). + +=== Use Cases +==== Preload records, then access them +Scenario: + +* We're writing a process `BackendStep` that uses `shipment` records as input. +* We need to know the `order` record associated with each `shipment` (via an `orderId` foreign key), for some business logic that isn't germaine to the explanation of `RecordLookupHelper`. +* We also to access some field on the `shippingPartner` record assigned to each `shipment`. +** Note that here, the `shipment` table has a `partnerCode` field, which relates to the `code` unique-key in the `shippingPartner` table. +** It's also worth mentioning, we only have a handful of `shippingPartner` records in our database, and we never expect to have very many more than that. + +[source,java] +.Example of a process step using a RecordLookupHelper to preload records +---- +public class MyShipmentProcessStep implements BackendStep +{ + // Add a new RecordLookupHelper field, which will "cache" both orders and shippingPartners + private RecordLookupHelper recordLookupHelper = new RecordLookupHelper(); + + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException; + { + // lookup the full shippingPartner table (because it's cheap & easy to do so) + // use the partner's "code" as the key field (e.g,. they key in the helper's internal map). + recordLookupHelper.preloadRecords("shippingPartner", "code"); + + // get all of the orderIds from the input shipments + List orderIds = input.getRecords().stream() + .map(r -> r.getValue("id")).toList(); + + // fetch the orders related to by these shipments + recordLookupHelper.preloadRecords("order", "id", orderIds); + + for(QRecord shipment : input.getRecords()) + { + // get someConfigField from the shippingPartner assigned to the shipment + Boolean someConfig = recordLookupHelper.getRecordValue("shippingPartner", "someConfigField", "code", shipment.getValue("partnerCode")); + + // get the order record assigned to the shipment + QRecord order = recordLookupHelper.getRecordByKey("order", "id", shipment.getValue("orderId")); + } + } +} +---- + +==== Lazy fetching records +Scenario: + +* We have a `BackendStep` that is taking in `purchaseOrderHeader` records, from an API partner. +* For each record, we need to make an API call to the partner to fetch the `purchaseOrderLine` records under that header. +** In this contrived example, the partner's API forces us to do these lookups order-by-order... +* Each `purchaseOrderLine` that we fetch will have a `sku` on it - a reference to our `item` table. +** We need to look up each `item` to apply some business logic. +** We assume there are very many item records in the backend database, so we don't want to pre-load the full table. +Also, we don't know what `sku` values we will need until we fetch the `purchaseOrderLine`. + +This is a situation where we can use `RecordLookupHelper` to lazily fetch the `item` records as we discover them, +and it will take care of not re-fetching ones that it has already loaded. + +[source,java] +.Example of a process step using a RecordLookupHelper to lazy fetch records +---- +public class MyPurchaseOrderProcessStep implements BackendStep +{ + // Add a new RecordLookupHelper field, which will "cache" lazy-loaded item records + private RecordLookupHelper recordLookupHelper = new RecordLookupHelper(); + + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException; + { + for(QRecord poHeader : input.getRecords()) + { + // fetch the lines under the header + Serializable poNo = poHeader.getValue("poNo"); + List poLines = new QueryAction().execute(new QueryInput("purchaseOrderLine") + .withFilter(new QQueryFilter(new QFilterCriteria("poNo", EQUALS, poNo)))); + + for(QRecord poLine : poLines) + { + // use recordLookupHelper to lazy-load item records by SKU. + QRecord item = recordLookupHelper.getRecordByKey("item", "sku", poLine.getValue("sku")); + + // business logic related to item performed here. + } + } + } +} +---- + +In this example, we will be doing exactly 1 query on the `item` table for each unique `sku` that is found across all of the `poLine` records we process. +That is to say, if the same `sku` appears on only 1 `poLine`, or if it appears on 100 `poLines`, we will still only query once for that `sku`. + +A slight tweak could be made to the example above, to make 1 `item` table lookup for each `poHeader` record: + +[source,java] +.Tweaked example doing 1 item lookup per poLine +---- +// continuing from above, after the List poLines has been built + +// get all of the skus from the lines +List skus = poLines.stream().map(r -> r.getValue("sku")).toList(); + +// preload the items for the skus +recordLookupHelper.preloadRecords("item", "sku", new QQueryFilter(new QFilterCriteria("sku", IN, skus))); + +for(QRecord poLine : poLines) +{ + // get the items from the helper + QRecord item = recordLookupHelper.getRecordByKey("item", "sku", poLine.getValue("sku")); + +---- + +In this example, we've made a trade-off: We will query the `item` table exactly 1 time for each `poHeader` that we process. +However, if the same `sku` is on every PO that we process, we will end up fetching it multiple times. + +This could end up being better or worse than the previous example, depending on the distribution of the data we are dealing with. + +A further tweak, a hybrid approach, could potentially reap the benefits of both of these examples (at the tradeoff of, more code, more complexity): + +[source,java] +.Tweaked example doing 1 item lookup per poLine, but only for not-previously-encountered skus +---- +// Critically - we must tell our recordLookupHelper to NOT do any one-off lookups in this table +recordLookupHelper.setMayNotDoOneOffLookups("item", "sku"); + +// continuing from above, after the List poLines has been built + +// get all of the skus from the lines +List skus = poLines.stream().map(r -> r.getValue("sku")).toList(); + +// determine which skus have not yet been loaded - e.g., they are not in the recordLookupHelper. +// this is why we needed to tell it above not to do one-off lookups; else it would lazy-load each sku here. +List skusToLoad = new ArrayList<>(); +for(Serializable sku : skus) +{ + if(recordLookupHelper.getRecordByKey("item", "sku", sku) == null) + { + skusToLoad.add(sku); + } +} + +// preload the item records for any skus that are still needed +if(!skusToLoad.isEmpty()) +{ + recordLookupHelper.preloadRecords("item", "sku", + new QQueryFilter(new QFilterCriteria("sku", IN, skusToLoad))); +} + +// continue as above + +---- + +In this example, we will start by querying the `item` table once for each `poHeader`, but, +if we eventually encounter a PO where all of its `skus` have already been loaded, then we may be able to avoid any `item` queries for such a PO. + +=== Implementation Details + +* Internally, an instance of `RecordLookupHelper` maintains a number of `Maps`, +with QQQ table names and field names as keys. +* The accessing/lazy-fetching methods (e.g., any method whose name starts with `getRecord`) +all begin by looking in these internal maps for the `tableName` and `keyFieldName` that they take as parameters. +** If they find an entry in the maps, then it is used for producing a return value. +** If they do not find an entry, then they will perform the a `QueryAction`, +to try to fetch the requested record from the table's backend. +*** Unless the `setMayNotDoOneOffLookups` method has been called for the `(tableName, keyFieldName)` pair. + +=== Full API + +==== Methods for accessing and lazy-fetching +* `getRecordByKey(String tableName, String keyFieldName, Serializable key)` + +Get a `QRecord` from `tableName`, where `keyFieldName` = `key`. + +* `getRecordValue(String tableName, String requestedField, String keyFieldName, Serializable key)` + +Get the field `requestedField` from the record in `tableName`, where `keyFieldName` = `key`, as a `Serializable`. +If the record is not found, `null` is returned. + +* `getRecordValue(String tableName, String requestedField, String keyFieldName, Serializable key, Class type)` + +Get the field `requestedField` from the record in `tableName`, where `keyFieldName` = `key`, as an instance of `type`. +If the record is not found, `null` is returned. + +* `getRecordId(String tableName, String keyFieldName, Serializable key)` + +Get the primary key of the record in `tableName`, where `keyFieldName` = `key`, as a `Serializable`. +If the record is not found, `null` is returned. + +* `getRecordId(String tableName, String keyFieldName, Serializable key, Class type)` + +Get the primary key of the record in `tableName`, where `keyFieldName` = `key`, as an instance of `type`. +If the record is not found, `null` is returned. + +* `getRecordByUniqueKey(String tableName, Map uniqueKey)` + +Get a `QRecord` from `tableName`, where the record matches the field/value pairs in `uniqueKey`. + +_Note: this method does not use the same internal map as the rest of the class. +As such, it does not take advantage of any data fetched via the preload methods. +It is only used for caching lazy-fetches._ + +==== Methods for preloading +* `preloadRecords(String tableName, String keyFieldName)` + +Query for all records from `tableName`, storing them in an internal map keyed by the field `keyFieldName`. + +* `preloadRecords(String tableName, String keyFieldName, QQueryFilter filter)` + +Query for records matching `filter` from `tableName`, +storing them in an internal map keyed by the field `keyFieldName`. + +* `preloadRecords(String tableName, String keyFieldName, List inList)` + +Query for records with the field `keyFieldName` having a value in `inList` from `tableName`, +storing them in an internal map keyed by the field `keyFieldName`. + +==== Config Methods +* `setMayNotDoOneOffLookups(String tableName, String fieldName)` + +For cases where you know that you have preloaded records for `tableName`, keyed by `fieldName`, +and you know that some of the keys may not have been found, +so you want to avoid doing a query when a missed key is found in one of the `getRecord...` methods, +then if you call this method, an internal flag is set, which will prevent any such one-off lookups. + +In other words, if this method has been called for a `(tableName, fieldName)` pair, +then the `getRecord...` methods will only look in the internal map for records, +and no queries will be performed to look for records.