𝐍𝐞𝐬𝐭𝐞𝐝 𝐆𝐚𝐥𝐥𝐞𝐫𝐲 𝐢𝐧 𝐏𝐨𝐰𝐞𝐫 𝐀𝐩𝐩𝐬 - 𝐆𝐫𝐨𝐮𝐩 & 𝐄𝐱𝐩𝐚𝐧𝐝/𝐂𝐨𝐥𝐥𝐚𝐩𝐬𝐞
Introduction
A common requirement in a Canvas App is to take a large list of records and make it easier to work with. One of the requests that comes up often is: group the records by a field, let users expand and collapse each group, and still allow the user to search and take actions on the records.
A normal Table control is useful for a flat list, but it does not provide the grouped, expandable experience we need. The approach in this article uses a parent Gallery for the groups and a nested Gallery for the records inside each group. The grouping itself is created in Power Fx using GroupBy().
The example below uses a simple Help Desk / Request Management scenario with Departments, Request Types, and Requests. The pattern is not specific to Help Desk. The same approach can be used for Customers, Projects, Regions, Categories, Managers, or other business data.
What this article covers
Why the Table control is not enough when the requirement becomes hierarchical.
The overall parent-gallery and nested-gallery architecture.
How GroupBy() converts a flat Dataverse dataset into grouped records.
How to implement expand/collapse using colExpandedGroups.
How the flexible-height gallery solves the reflow problem.
How search and sorting are applied before grouping.
How child fields stay aligned with a static gallery header.
How multi-select is implemented with colSelectedItems.
How the selected records can be updated in bulk.
A few Dataverse-specific issues that are useful to know when applying the pattern.
1. The Problem: Flat Data, Hierarchical Requirement
The starting point is a normal list of Requests. Each row contains fields such as Request Number, Request Type, Department, Priority, Status, Due Date, Assigned To, and Owner.
The Table control already gives us useful functionality such as multi-select and sorting. The problem is that the data is still flat. If the user has thousands of records, it becomes difficult to scan the list or focus on one department at a time.
The requirement is therefore slightly different: the user should see a group header such as IT or HR, expand that group, see only its requests, and collapse it again when finished.
2. Architecture: Two Galleries, One Data Transformation
The solution has three main pieces. First, Power Fx transforms the flat Requests table into grouped data. Second, the outer Gallery renders one row per group. Third, a child Gallery inside the outer Gallery renders the requests that belong to the current group.
Dataverse Requests
|
v
Filter
|
v
Sort
|
v
Create GroupKey
|
v
GroupBy()
|
v
Sort the Groups
|
v
GroupsGallery
|
+---- RequestsGallery
(ThisItem.GroupedRecords)
There is an important distinction here. This is not the usual nested-gallery pattern where a parent record has a formal one-to-many relationship with child records, such as an Invoice and its Line Items. Here, the source data is flat and the grouping is created at runtime with GroupBy().
That makes the pattern reusable. The grouping key can be a Department, Customer, Region, Project, Category, Manager, or another business field.
3. Dataverse Data Model
The example uses three main Dataverse tables. The canvas app also uses Users and Teams as supporting data sources for the user/profile and Owner display.
The Department field on Requests is used to determine the group. Display Order on Departments is used to control the order of the group headers without hard-coding IT, HR, Finance, and so on in the Canvas App.
4. Screen and Component Overview
The application uses a few supporting components around the gallery. These provide the search, column headers, user information, and bulk actions while the main focus remains the nested-gallery pattern. The header, gallery header, and action button containers are straightforward layout pieces, so the sections below describe what each one contains and how it's configured, rather than treating them as the main event.
RequestsScreen
|
+-- HeaderContainer
|
+-- MainContainer
| |
| +-- SearchRecordsText
| |
| +-- GalleryHeaderContainer
| |
| +-- GroupsGallery
| |
| +-- GroupChevronIcon
| +-- GroupHeaderLbl
| |
| +-- RequestsGallery
| |
| +-- SelectCheckbox
| +-- RequestNumberLbl
| +-- TitleLbl / other field labels
| +-- RequestTypeLbl
| +-- PriorityLbl
| +-- StatusLbl
| +-- DueDateLbl
| +-- AssignedToLbl
| +-- OwnerLbl
|
+-- NoRecordsLbl
|
+-- ButtonContainer
|
+-- MarkInProgressButton
+-- MarkCompletedButton
The HeaderContainer provides the application title, refresh action, and current-user information. The GalleryHeaderContainer is the static row of column headers. The Buttons at the bottom use the selected-record collection for bulk actions.
5. Add the Supporting Header
The top header is a supporting application component rather than part of the nested-gallery logic. Add a HeaderContainer at the top of the screen to provide the application title, refresh action, and current-user information.
The main purpose is to keep the refresh action, application title, and user profile available without distracting from the gallery implementation.
6. Add the Search Box and Gallery Header
The search input sits above the data area. On its own, it doesn't do any filtering — it only captures what the user types. The actual filtering happens later, inside GroupsGallery.Items.
The GalleryHeaderContainer is the static column-header row that sits above GroupsGallery. It's laid out horizontally, and each individual header control has its own width, which together define where each column sits. The point of setting these up as a distinct row of controls — rather than just typing "Request #", "Title", and so on as plain text — is that every field inside the nested gallery will align itself to these exact headers a bit later, instead of using fixed pixel positions.
7. Create the Parent GroupsGallery
Insert a blank flexible-height Gallery and rename it GroupsGallery. This is the outer gallery and it will represent groups, not individual requests.
The flexible-height variant is important because the height of a group changes when its child Gallery is expanded or collapsed. The outer Gallery needs to re-measure its template and move the groups below it.
8. Build the Complete Grouping Formula
The complete GroupsGallery.Items formula is easier to understand if it is read from the inside out. The complete formula is shown first and then explained step by step from the inside out.
SortByColumns(
AddColumns(
GroupBy(
AddColumns(
Sort(
Filter(
Requests,
IsBlank(SearchRecordsText.Value) ||
SearchRecordsText.Value in 'Request Name' ||
SearchRecordsText.Value in 'Request number' ||
SearchRecordsText.Value in Text(Request) ||
SearchRecordsText.Value in 'Assigned To'
),
'Due Date',
SortOrder.Ascending
),
GroupKey,
Department.'Department Name'
),
GroupKey,
GroupedRecords
),
SortRank,
LookUp(
Departments,
'Department Name' = GroupKey,
'Display Order'
)
),
"SortRank",
SortOrder.Ascending
)
8.1 Filter the records first
The innermost Filter() is where the search is applied. If the search input is empty, all Requests are returned. Otherwise, only matching records continue through the rest of the formula.
Filter(
Requests,
IsBlank(SearchRecordsText.Value) ||
SearchRecordsText.Value in 'Request Name' ||
SearchRecordsText.Value in 'Request number' ||
SearchRecordsText.Value in Text(Request) ||
SearchRecordsText.Value in 'Assigned To'
)
Filtering before GroupBy() is important. The group counts and the records inside each group then reflect the current search result, rather than showing groups based on the full unfiltered table.
8.2 Sort the individual records
Sort(
Filter(...),
'Due Date',
SortOrder.Ascending
)
This controls the order of the individual Requests within each group. The current implementation uses ascending Due Date, so earlier due dates appear first.
8.3 Create GroupKey
AddColumns(
Sort(...),
GroupKey,
Department.'Department Name'
)
GroupKey is created dynamically. It is not a Dataverse column. In this example the Department lookup is used to read Department Name. Using a generic name such as GroupKey also makes the pattern easier to reuse in other apps.
8.4 Group the records with GroupBy()
GroupBy(
AddColumns(...),
GroupKey,
GroupedRecords
)
This is the main transformation. Instead of one row for every Request, the result contains one row for every unique GroupKey. Each group row carries a nested table called GroupedRecords, containing the Requests that belong to that group.
8.5 Add SortRank and sort the groups
AddColumns(
GroupBy(...),
SortRank,
LookUp(
Departments,
'Department Name' = GroupKey,
'Display Order'
)
)
SortByColumns(
...,
"SortRank",
SortOrder.Ascending
)
The first Sort() in the formula orders Requests. The final SortByColumns() orders the groups. Display Order is stored in Dataverse, so the business can change group order without changing the Canvas App formula.
9. Add the Nested RequestsGallery
Inside the GroupsGallery template, insert a standard vertical Gallery and rename it RequestsGallery. The nested Gallery should be a direct child of the outer Gallery template. Do not wrap it in another Group or container, because the variable-height reflow relies on the nested Gallery being directly inside the template.
RequestsGallery.Items
ThisItem.GroupedRecords
The parent Gallery has already performed the grouping. The child Gallery only needs to read the GroupedRecords table belonging to the current group row.
10. Initialize the Client-Side State
Two collections are used for UI state. colExpandedGroups tracks which groups are open, while colSelectedItems tracks which Requests the user has selected.
10.1 App.OnStart
ClearCollect(colSelectedItems, FirstN(Requests, 0));
ClearCollect(colExpandedGroups, [{GroupKey: "***init***"}]);
Clear(colExpandedGroups);
The first line creates an empty selection collection with the same record shape as Requests. The second and third lines establish the GroupKey column in colExpandedGroups and then clear the temporary row so the app starts with no expanded groups.
11. Add the Group Header and Chevron
Inside GroupsGallery, add a group header label and a chevron icon. The label shows the department name and current record count. The chevron indicates whether the group is expanded or collapsed.
GroupHeaderLbl.Text
ThisItem.GroupKey &
" (" &
CountRows(ThisItem.GroupedRecords) &
")
The parent Gallery row now represents a group rather than a Request, so ThisItem.GroupKey is the department value for the current group. CountRows(ThisItem.GroupedRecords) gives the number of matching Requests in that group.
11.1 Chevron state
GroupChevronIcon.Icon
If(
!IsEmpty(
Filter(
colExpandedGroups,
GroupKey = ThisItem.GroupKey
)
),
Icon.ChevronDown,
Icon.ChevronRight
)
The icon reads the same colExpandedGroups collection used by RequestsGallery.Visible. That keeps the visual state and the actual expansion state in sync.
11.2 Expand/collapse OnSelect
GroupHeaderLbl.OnSelect and GroupChevronIcon.OnSelect
If(
!IsEmpty(
Filter(
colExpandedGroups,
GroupKey = ThisItem.GroupKey
)
),
ForAll(
ThisItem.GroupedRecords As Row,
Remove(
colSelectedItems,
LookUp(
colSelectedItems,
'Request number' = Row.'Request number'
)
)
);
Remove(
colExpandedGroups,
LookUp(
colExpandedGroups,
GroupKey = ThisItem.GroupKey
)
),
Collect(
colExpandedGroups,
{GroupKey: ThisItem.GroupKey}
)
)
The same OnSelect formula is used on the group label and chevron. If the group is already expanded, it is removed from colExpandedGroups. If the group is collapsed, its GroupKey is collected.
There is one deliberate UX rule in the collapse branch: selected records belonging to the group are also removed from colSelectedItems. That prevents hidden records from remaining selected for a later bulk action.
12. Control the Nested Gallery Visibility and Height
RequestsGallery.Visible
!IsEmpty(
Filter(
colExpandedGroups,
GroupKey = ThisItem.GroupKey
)
)
RequestsGallery.Height
If(
Self.Visible,
CountRows(ThisItem.GroupedRecords) * 52,
0
)
The child Gallery is visible only when its GroupKey exists in colExpandedGroups. Its height is the number of grouped records multiplied by the fixed child row height of 52 pixels.
The key point is that the child Gallery does not use a fixed height large enough for every group. The height changes with the number of records. Because GroupsGallery is the flexible-height variant, the groups below it move down when the child Gallery grows and move back up when it collapses.
13. The Reflow Problem: Why Flexible Height Matters
It is useful to understand what happens when the parent Gallery is a normal Vertical Gallery. If the child Gallery grows but the parent template does not re-measure its height, the expanded records can overlap the next group instead of pushing it down.
A similar issue occurs if the inner Gallery is wrapped inside a Group container and the container height is changed programmatically. For this pattern, the reliable structure is a variable-height outer Gallery with the child Gallery directly inside the template.
14. Align the Child Fields with the Header
The static GalleryHeaderContainer sits above the grouped data. The fields inside RequestsGallery should use the corresponding header controls for X and Width instead of unrelated hard-coded values.
Example: RequestNumberLbl.X
GalleryHeaderContainer.X + RequestNumberHeader.X
Example: RequestNumberLbl.Width
RequestNumberHeader.Width
The same pattern is repeated for Department, Request Type, Priority, Status, Due Date, Assigned To, Owner, and the other displayed fields. This keeps the data columns directly underneath their static headers and makes the layout more stable when the screen width changes.
14.1 Typical field mapping
Use the exact display name of your fields in the app. The important pattern is not the control naming; it is that each row field is bound to ThisItem and aligned to its matching header.
15. Add Multi-Select to the Nested Gallery
The Table control provides SelectedItems automatically. A Gallery does not provide the same built-in multi-select behavior, so the selection state is maintained explicitly in colSelectedItems.
SelectCheckbox.Default
!IsEmpty(
Filter(
colSelectedItems,
'Request number' = ThisItem.'Request number'
)
)
SelectCheckbox.OnCheck
Collect(
colSelectedItems,
ThisItem
)
SelectCheckbox.OnUncheck
Remove(
colSelectedItems,
LookUp(
colSelectedItems,
'Request number' = ThisItem.'Request number'
)
)
The checkbox simply reflects the collection. OnCheck adds the current record. OnUncheck removes it. The collection is therefore the source of truth for selection.
16. Bulk Actions
The selected records can now be used for a bulk business action. In the example application there are two buttons: Mark In Progress and Mark Completed.
16.1 Mark In Progress
MarkInProgressButton.DisplayMode
If(
CountRows(colSelectedItems) = 0,
DisplayMode.Disabled,
DisplayMode.Edit
)
MarkInProgressButton.OnSelect
ForAll(
colSelectedItems,
Patch(
Requests,
ThisRecord,
{
cr3d5_status:
'Status (Requests)'.'In Progress'
}
)
);
Refresh(Requests);
Clear(colSelectedItems);
Reset(GroupsGallery);
Clear(colExpandedGroups);
Notify(
"Status updated",
NotificationType.Success,
2000
)
The formula loops through the selected Requests and updates the status in Dataverse. It then refreshes the Requests source, clears selection, resets the parent Gallery, closes the expanded groups, and shows a success notification.
16.2 Mark Completed
MarkCompletedButton.DisplayMode
If(
CountRows(colSelectedItems) = 0,
DisplayMode.Disabled,
DisplayMode.Edit
)
MarkCompletedButton.OnSelect
ForAll(
colSelectedItems,
Patch(
Requests,
ThisRecord,
{
cr3d5_status:
'Status (Requests)'.'Closed'
}
)
);
Refresh(Requests);
Clear(colSelectedItems);
Reset(GroupsGallery);
Clear(colExpandedGroups);
Notify(
"Status updated",
NotificationType.Success,
2000
)
The second button follows the same pattern, but updates the selected records to the Closed choice value.
17. Refresh and Empty State
17.1 Refresh
RefreshIcon.OnSelect
Refresh(Requests);
Refresh(Departments);
Refresh('Request Types');
Notify(
"Data refreshed successfully",
NotificationType.Success,
2000
)
The refresh action gives the user a simple way to get the latest data if records have been changed elsewhere.
17.2 Empty state
NoRecordsLbl.Visible
IsEmpty(GroupsGallery.AllItems)
NoRecordsLbl can display a simple message such as "No requests found" when a search returns no matching records.
18. Dataverse Notes and Common Gotchas
Most of the pattern is generic Power Fx, but Dataverse introduces a few details worth keeping in mind.
18.1 Choice columns
Switch(
Text(ThisItem.Priority),
"High", RGBA(196, 50, 50, 1),
"Medium", RGBA(204, 133, 0, 1),
"Low", RGBA(0, 120, 212, 1),
RGBA(32, 32, 32, 1)
)
Converting a Choice value to text can make comparisons straightforward when the UI needs to apply a color or branch based on the displayed choice value.
18.2 Naming collisions with system columns
Dataverse tables already have system columns such as Status, Status Reason, Owner, Created On, and Modified On. If you create a custom column using a system name, Power Apps may disambiguate it in formulas. This is why a Patch formula can contain a reference such as 'Status (Requests)'.
Patch(
Requests,
ThisRecord,
{
cr3d5_status:
'Status (Requests)'.'In Progress'
}
)
For new designs, it is cleaner to avoid naming custom columns after Dataverse system fields.
18.3 Owner is a polymorphic lookup
If(
IsType(ThisItem.Owner, Users),
AsType(ThisItem.Owner, Users).'Full Name',
IsType(ThisItem.Owner, Teams),
AsType(ThisItem.Owner, Teams).'Team Name',
"Unassigned"
)
Owner can point to a User or a Team. Users and Teams should therefore be available as data sources when this formula is used.
18.4 Search and delegation
The example search includes Text(Request) to search the Request field. If the field is a large/memo-style column, converting it with Text() can force client-side evaluation and therefore limit scalability.
SearchRecordsText.Value in Text(Request)
For a small or moderate demo this is acceptable. For large production datasets, use searchable text fields or another server-side search approach rather than depending on a non-delegable memo-field search.
19. Putting It All Together
Dataverse
|
v
Filter + Search
|
v
Sort Requests by Due Date
|
v
Add GroupKey
|
v
GroupBy() -> GroupedRecords
|
v
Add SortRank
|
v
Sort Groups by Display Order
|
v
GroupsGallery
|
+--> GroupHeaderLbl / GroupChevronIcon
|
+--> RequestsGallery
|
+--> SelectCheckbox
+--> Request fields
|
+--> colExpandedGroups
+--> colSelectedItems
|
v
Bulk Patch -> Refresh Dataverse
The final pattern is straightforward once the data transformation is understood. The Requests table starts flat. Power Fx filters and sorts it, adds a grouping key, groups the records, and orders the groups. GroupsGallery renders those group rows, while RequestsGallery renders the GroupedRecords for the current group.
20. Where This Pattern Can Be Reused
The Help Desk example is illustrative; the same pattern can be reused anywhere a flat dataset needs to become a grouped interface
The reusable idea is not the Help Desk screen. It is the data and UI pattern:
Flat data -> GroupBy() -> Parent Gallery -> Nested Gallery -> Expand/Collapse -> Dynamic Height -> Selection / Actions
Conclusion
The main idea behind this implementation is simple: take a flat Dataverse dataset, reshape it with Power Fx, and then let the Canvas App render that grouped shape through two Galleries.
The outer Gallery represents the groups. The nested Gallery represents the records inside each group. colExpandedGroups controls the open and closed state, and the child Gallery height is calculated from the number of records so the parent Gallery can reflow correctly.
Once that foundation is in place, search, selection, and bulk actions can be added without changing the basic architecture.
That is what makes this pattern useful beyond the Help Desk example. The grouping field can change, the child fields can change, and the business action can change, while the core nested-gallery design remains the same.