Use Selection For Bulk Actions in an Inline Editable Grid

SAIL Recipes give you an opportunity to explore different interface design patterns. To learn how to directly use SAIL recipes within your interfaces, see Adapt a SAIL Recipe to Work with My Applications.

Goal

Allow the user to edit data inline in a grid one field at a time, or in bulk using selection.

This design pattern is not recommended for offline interfaces because reflecting immediate changes in an interface based on user interaction requires a connection to the server.

image:SAIL_Recipe_Editable_Grid_using_Selection_for_Bulk_Actions.png

This scenario demonstrates:

  • How to use the grid layout component to build an inline editable grid
  • How to use selection to enable bulk actions
  • How to make a cell conditionally required based on the value in another cell

Setup

This example makes use of a custom data type. Create a PrItem custom data type with the following fields:

  • id (Number (Integer))
  • summary (Text)
  • qty (Number (Integer))
  • unitPrice (Number (Decimal))
  • dept (Text)
  • due (Date)
  • decision (Text)
  • reason (Text)

We use Text as the data type for dept and decision for simplicity. Typically, these fields would reference a lookup table.

Now that we've created the supporting data type, let's move on to the main expression.

Expression

=load(
  local!items: {
    type!PrItem(id: 1, summary: "Item 1", qty: 1, unitPrice: 10, dept: "Sales",   due: today() + 10),
    type!PrItem(id: 2, summary: "Item 2", qty: 2, unitPrice: 20, dept: "Finance", due: today() + 20),
    type!PrItem(id: 3, summary: "Item 3", qty: 3, unitPrice: 30, dept: "Sales",   due: today() + 30)
  },
  local!selectedIndices: tointeger({}),
  a!formLayout(
    label: "SAIL Example: Inline Editable Grid using Selection for Bulk Actions",
    contents: {
      a!buttonArrayLayout(buttons: {
        a!buttonWidget(
          label: "Approve",
          value: "Approve",
          /* You can save into a field at many indices at a time `*/
          saveInto: {
            local!items[local!selectedIndices].decision,
            /*` Clear the selected indices after a decision is made `*/
            a!save(local!selectedIndices, tointeger({}))
          },
          disabled: count(local!selectedIndices) = 0
        ),
        a!buttonWidget(
          label: "Reject",
          value: "Reject",
          saveInto: {
            local!items[local!selectedIndices].decision,
            /*` Clear the selected indices after a decision is made `*/
            a!save(local!selectedIndices, tointeger({}))
          },
          disabled: count(local!selectedIndices) = 0
        ),
        a!buttonWidget(
          label: "Need More Info",
          value: "Need More Info",
          saveInto: {
            local!items[local!selectedIndices].decision,
            /*` Clear the selected indices after a decision is made */
            a!save(local!selectedIndices, tointeger({}))
          },
          disabled: count(local!selectedIndices) = 0
        )
      }),
      a!gridLayout(
        headerCells: {
          a!gridLayoutHeaderCell(label: "Summary"),
          a!gridLayoutHeaderCell(label: "Qty", align: "RIGHT"),
          a!gridLayoutHeaderCell(label: "U/P", align: "RIGHT"),
          a!gridLayoutHeaderCell(label: "Amount", align: "RIGHT"),
          a!gridLayoutHeaderCell(label: "Department"),
          a!gridLayoutHeaderCell(label: "Due", align: "RIGHT"),
          a!gridLayoutHeaderCell(label: "Decision"),
          a!gridLayoutHeaderCell(label: "Reason")
        },
        columnConfigs: {
          a!gridLayoutColumnConfig(width: "DISTRIBUTE", weight: 5),
          a!gridLayoutColumnConfig(width: "DISTRIBUTE"),
          a!gridLayoutColumnConfig(width: "DISTRIBUTE"),
          a!gridLayoutColumnConfig(width: "DISTRIBUTE", weight: 2),
          a!gridLayoutColumnConfig(width: "DISTRIBUTE", weight: 3),
          a!gridLayoutColumnConfig(width: "DISTRIBUTE", weight: 3),
          a!gridLayoutColumnConfig(width: "DISTRIBUTE", weight: 3),
          a!gridLayoutColumnConfig(width: "DISTRIBUTE", weight: 3)
        },
        rows: a!forEach(
          items:local!items,
          expression:{
            a!gridRowLayout(
              id: fv!index,
              contents: {
                a!textField(
                  /* Labels are not visible in grid cells but are necessary to meet accessibility requirements */
                  label: "summary " & fv!index,
                  value: fv!item.summary,
                  readOnly: true
                ),
                a!integerField(
                  label: "qty " & fv!index,
                  value: fv!item.qty,
                  readOnly: true,
                  align: "RIGHT"
                ),
                a!floatingPointField(
                  label: "unitPrice " & fv!index,
                  value: fv!item.unitPrice,
                  readOnly: true,
                  align: "RIGHT"
                ),
                a!textField(
                  label: "amount " & fv!index,
                  value: if(
                    or(isnull(fv!item.qty), isnull(fv!item.unitPrice)),
                    null,
                    dollar(fv!item.qty * fv!item.unitPrice)
                  ),
                  readOnly: true,
                  align: "RIGHT"
                ),
                a!dropdownField(
                  label: "dept " & fv!index,
                  choiceLabels: {"Finance", "Sales"},
                  placeholderLabel: "--Select-- ",
                  choiceValues: {"Finance", "Sales"},
                  value: fv!item.dept,
                  disabled: true
                ),
                a!dateField(
                  label: "due " & fv!index,
                  value: fv!item.due,
                  readOnly: true,
                  align: "RIGHT"
                ),
                a!dropdownField(
                  label: "decision " & fv!index,
                  choiceLabels: {"Approve", "Reject", "Need More Info"},
                  placeholderLabel: "--Select-- ",
                  choiceValues: {"Approve", "Reject", "Need More Info"},
                  value: fv!item.decision,
                  saveInto: fv!item.decision,
                  required: true
                ),
                a!textField(
                  label: "reason" & fv!index,
                  value: fv!item.reason,
                  saveInto: fv!item.reason,
                  required: and(
                    not(isnull(fv!item.decision)),
                    fv!item.decision <> "Approve"
                  ),
                  requiredMessage: "A reason is required for items that are not approved"
                )
              }
            )
          }
        ),
        selectable: true,
        selectionValue: local!selectedIndices,
        selectionSaveInto: local!selectedIndices
      ),
      a!textField(
        label: "Selected Values",
        labelPosition: "ADJACENT",
        instructions: typename(typeof(local!selectedIndices)),
        value: local!selectedIndices,
        readOnly: true
      )
    },
    buttons: a!buttonLayout(
      primaryButtons: a!buttonWidgetSubmit(
        label: "Submit"
      )
    )
  )
)

Test it out

  1. Select a decision inline in the grid.
  2. Select a couple rows using the selection checkboxes and notice that the buttons are enabled above the grid. Click on a button and notice that the decision field is updated for the selected rows.
  3. Select "Reject" or "Need More Info", leave the corresponding reason blank, and attempt to submit to see the required message.

Notable implementation details

  • You can save into a field in an array of custom data type values directly without having to use looping functions or rules i.e. saveInto: ri!cdtArray[{1,2,3}].fieldName. Use this design pattern when the form has access to the entire array of data, and the data is not being modified outside the form. Otherwise, create a rule to find the items to update by the item id.
  • Notice that the a!forEach() function for the grid rows has access to the items across all rows, as well as all the fields of the item within its own row. This is how you're able to dynamically calculate values across columns, and if necessary, across rows.
  • The component in each cell has a label, but the label isn't displayed. Labels and instructions aren't rendered within a grid cell. It is useful to enter a label for expression readability, and to help identify which cell has an expression error since the label is displayed in the error message. Labels are also necessary to meet accessibility requirements, so that a screen reader can properly parse the grid.
FEEDBACK