Universal Paging

When designing a user interface meant to display larger volumes of data to the user, we have to consider two important restrictions. These are available memory and screen space. The read-only grid in Appian does manage both in a clever way, as it tries to query and display data in batches defined by a start index and a batch size. The user can then navigate pages forwards and backwards when browsing through a larger dataset. Keep in mind to support your users with a search function because computers are always better in searching than human eyes feeding image data to brains.

There are situations in which we do not want to use a read-only grid, but want to display lists of data differently while keeping the option for paging. In this post, I want to share my UI component that mimics the normal Appian paging control and can be applied to any kind of lists.

The Paging Component

Find the code for this component below. The only tricky part is to calculate the next or previous start index based on the batch size. I have to make sure that the start index never becomes smaller than 1, but also never larger than the total count of items in the list. Take a look at the saveInto logic on each of the navigation icons.

The parameters are:

  • pagingInfo (PagingInfo): The paging info used to sort and navigate through the dataset. Use a!pagingInfo().
  • saveInto (List of Save): The pagingInfo modified by the user interaction will be saved to.
  • totalCount (Number (Integer)): The total size of the dataset. In case the total count is smaller than the batch size, the paging control is hidden as there is only a single page.
  • ofLabel (Text): The label used in the control between the current page and the max page number.
  • size (Text): Determines the text size. Valid values: “SMALL”, “STANDARD” (default), “MEDIUM”, “MEDIUMPLUS”, “LARGE”, “LARGEPLUS”, “EXTRALARGE”.
  • align (Text): Determines alignment of the text value. Valid values: “LEFT”, “CENTER”, “RIGHT”.
  • showWhen (Boolean): Determines whether the component is displayed on the interface. When set to false, the component is hidden and is not evaluated. Default: true.
if(
  or(
    a!isNullOrEmpty(ri!pagingInfo),
    a!isNullOrEmpty(ri!totalCount)
  ),
  a!richTextDisplayField(
    labelPosition: "COLLAPSED",
    value: a!richTextItem(
      showWhen: or(
        a!isNullOrEmpty(ri!pagingInfo),
        a!isNullOrEmpty(ri!totalCount)
      ),
      text: concat(
        if(a!isNullOrEmpty(ri!pagingInfo), "A paging info is required.", {}),
        if(a!isNullOrEmpty(ri!totalCount), " Total count must not be NULL.", {}),
        char(10)
      ),
      style: "STRONG",
      color: "NEGATIVE"
    )
  ),
  a!richTextDisplayField(
    labelPosition: "COLLAPSED",
    align: ri!align,
    showWhen: and(
      ri!showWhen <> false,
      ri!totalCount > ri!pagingInfo.batchSize,
    ),
    value: {
      a!richTextIcon(
        icon: "angle-double-left",
        link: a!dynamicLink(
          saveInto: {
            a!save(
              target: ri!saveInto,
              value: a!pagingInfo(
                startIndex: 1,
                batchSize: ri!pagingInfo.batchSize,
                sort: ri!pagingInfo.sort
              )
            )
          },
          showWhen: ri!pagingInfo.startIndex <> 1
        ),
        linkStyle: "STANDALONE",
        color: if(
          ri!pagingInfo.startIndex <> 1,
          "STANDARD",
          "SECONDARY"
        ),
        size: a!defaultValue(ri!size, "MEDIUM_PLUS")
      ),
      a!richTextIcon(
        icon: "angle-left",
        link: a!dynamicLink(
          saveInto: {
            a!save(
              target: ri!saveInto,
              value: a!pagingInfo(
                startIndex: if(
                  ri!pagingInfo.startIndex - ri!pagingInfo.batchSize < 1,
                  1,
                  ri!pagingInfo.startIndex - ri!pagingInfo.batchSize
                ),
                batchSize: ri!pagingInfo.batchSize,
                sort: ri!pagingInfo.sort
              )
            ),
          },
          showWhen: ri!pagingInfo.startIndex <> 1
        ),
        linkStyle: "STANDALONE",
        color: if(
          ri!pagingInfo.startIndex <> 1,
          "STANDARD",
          "SECONDARY"
        ),
        size: a!defaultValue(ri!size, "MEDIUM_PLUS")
      ),
      " ",
      a!richTextItem(
        text: {
          a!defaultValue(ri!pagingInfo.startIndex, "0"),
          " - ",
          if(
            a!isNullOrEmpty(ri!pagingInfo.startIndex),
            "0",
            if(
              ri!pagingInfo.startIndex + ri!pagingInfo.batchSize - 1 > ri!totalCount,
              ri!totalCount,
              ri!pagingInfo.startIndex + ri!pagingInfo.batchSize - 1
            )
          )
        },
        size: a!defaultValue(ri!size, "MEDIUM"),
        style: "STRONG"
      ),
      a!richTextItem(
        text: {
          " ",
          a!defaultValue(ri!ofLabel, "of"),
          " ",
          a!defaultValue(ri!totalCount, "0", fixed(ri!totalCount, 0))
        },
        size: a!defaultValue(ri!size, "MEDIUM")
      ),
      " ",
      a!richTextIcon(
        icon: "angle-right",
        link: a!dynamicLink(
          saveInto: {
            a!save(
              target: ri!saveInto,
              value: a!pagingInfo(
                startIndex: ri!pagingInfo.startIndex + ri!pagingInfo.batchSize,
                batchSize: ri!pagingInfo.batchSize,
                sort: ri!pagingInfo.sort
              )
            ),
          },
          showWhen: ri!pagingInfo.startIndex + ri!pagingInfo.batchSize - 1 < ri!totalCount
        ),
        linkStyle: "STANDALONE",
        color: if(
          ri!pagingInfo.startIndex + ri!pagingInfo.batchSize - 1 < ri!totalCount,
          "STANDARD",
          "SECONDARY"
        ),
        size: a!defaultValue(ri!size, "MEDIUM_PLUS")
      ),
      a!richTextIcon(
        icon: "angle-double-right",
        link: a!dynamicLink(
          saveInto: {
            a!save(
              target: ri!saveInto,
              value: a!pagingInfo(
                /* When jumping to the last page, make sure that the startIndex is an even multiple of batch size. *
                 * This ensures that you have the same last page as if you had gotten there one page at a time.    */
                startIndex: if(
                  mod(ri!totalCount, ri!pagingInfo.batchSize) = 0,
                  ri!totalCount - ri!pagingInfo.batchSize + 1,
                  ri!totalCount - mod(ri!totalCount, ri!pagingInfo.batchSize) + 1
                ),
                batchSize: ri!pagingInfo.batchSize,
                sort: ri!pagingInfo.sort
              )
            ),
          },
          showWhen: ri!pagingInfo.startIndex + ri!pagingInfo.batchSize - 1 < ri!totalCount
        ),
        linkStyle: "STANDALONE",
        color: if(
          ri!pagingInfo.startIndex + ri!pagingInfo.batchSize - 1 < ri!totalCount,
          "STANDARD",
          "SECONDARY"
        ),
        size: a!defaultValue(ri!size, "MEDIUM_PLUS")
      )
    }
  )
)

Using it

That’s simple, Just let the component manage your paging info that you probably store in a local variable and feed into your query. This can be queryEntity() or queryRecordType().

There is one more trick I want to share with you. This is about paging in case you have data in a rule input and want to create an editable grid that directly modifies this list. In this case, you cannot use the todatasubset() function, as it returns a copy of the data but not a subset of the data in that rule input. The only way to make this work, is to use angular brackets to fetch a few items from that list.

Indexing into a list of items using angular brackets returns items that directly reference the items in the original list, not copies. When feeding this into an editable grid, you can use fv!item to directly write to that item in the rule input.

Based on the information in the paging info, you can easily calculate the indexes to fetch the batch of items. To ensure that the end of the list is not exceeded, simply calculate the intersection of the calculated indexes with the list of total indexes. Even I there is only a single item on the last page, this will work just fine.

ri!wipItems[
  /* This makes sure that the indexed number stay within boundaries */
  intersection(
    local!pInfo.startIndex + enumerate(local!pInfo.batchSize),
    1 + enumerate(local!wipItemsTotalCount)
  )
]

My example code for an editable grid:

...
rows: {
  a!forEach(
    items: ri!items[
    /* This makes sure that the indexed number stay within boundaries */
    intersection(
      local!pInfo.startIndex + enumerate(local!pInfo.batchSize),
      1 + enumerate(local!wipItemsTotalCount)
    )
  ],
  expression: a!gridRowLayout(
    contents: {
...

But this can be used for any way of designing list items, whether you use card layouts, sections, boxes, or column layouts.

Summary

Besides some mathematical trickery to calculate the start index for the next or previous batch, the important insight is how to access items in lists in Appian. Using index(), property(), todatasubset() or displayvalue(), you will always get a copy of the data. Whenever you want to modify the original items, you have to use angular brackets.

I haven’t checked if this also affects memory consumption. Appian does not provide any means of monitoring memory utilisation on expression level. But if you are working with a dataset where you have to worry about memory, you should design your solution differently anyway.

I hope this inspires you in building more flexible user interfaces in Appian. I am curious about what you use this for. Let me hear in the comments below.

Leave a Reply