diff --git a/py/examples/table_groups.py b/py/examples/table_groups.py
index e63530ef595..7cc72781e64 100644
--- a/py/examples/table_groups.py
+++ b/py/examples/table_groups.py
@@ -21,7 +21,8 @@ async def serve(q: Q):
ui.table_row(name='row3', cells=['Issue3']),
ui.table_row(name='row4', cells=['Issue4']),
ui.table_row(name='row5', cells=['Issue5']),
- ], collapsed=False)],
+ ], collapsed=False),
+ ui.table_group("Jane", [])],
height='500px'
)
])
diff --git a/ui/src/table.test.tsx b/ui/src/table.test.tsx
index d9e9667f705..39e2fc38039 100644
--- a/ui/src/table.test.tsx
+++ b/ui/src/table.test.tsx
@@ -23,7 +23,6 @@ const
cell21 = 'Jumps over a dog.',
cell31 = 'Wooo hooo.',
headerRow = 1,
- groupHeaderRow = 1,
groupHeaderRowsCount = 2,
filteredItem = 1,
emitMock = jest.fn(),
@@ -720,7 +719,7 @@ describe('Table.tsx', () => {
// Search
expect(getAllByRole('row')).toHaveLength(tableProps.rows!.length + headerRow + groupHeaderRowsCount)
fireEvent.change(getByTestId('search'), { target: { value: 'No match!' } })
- expect(getAllByRole('row')).toHaveLength(headerRow)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount)
fireEvent.change(getByTestId('search'), { target: { value: '' } })
expect(getAllByRole('row')).toHaveLength(tableProps.rows!.length + headerRow + groupHeaderRowsCount)
@@ -1079,6 +1078,75 @@ describe('Table.tsx', () => {
expect(getAllByRole('row')).toHaveLength(headerRow + tableProps.rows!.length)
})
+ it("Checks if empty groups are shown - filter", () => {
+ const
+ { container, getAllByText, getAllByRole, getByTestId } = render(),
+ expectAllGroupsToBeVisible = () => {
+ expect(getAllByText('Group1')[0]).toBeVisible()
+ expect(getAllByText('Group2')[0]).toBeVisible()
+ },
+ expectAllItemsToBePresent = () => {
+ const [firstGroupHeader, secondGroupHeader] = container.querySelectorAll('.ms-GroupHeader-title')
+ expect(firstGroupHeader).toHaveTextContent('Group1(2)')
+ expect(secondGroupHeader).toHaveTextContent('Group2(1)')
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + tableProps.rows!.length)
+ }
+
+ fireEvent.click(getByTestId('groupby'))
+ fireEvent.click(getAllByText('Col2')[1]!)
+ fireEvent.click(container.querySelector('.ms-DetailsHeader-collapseButton')!)
+
+ expectAllGroupsToBeVisible()
+ expectAllItemsToBePresent()
+
+ fireEvent.click(container.querySelector('.ms-DetailsHeader-filterChevron')!)
+ fireEvent.click(getAllByText('Group1')[3].parentElement!)
+
+ expectAllGroupsToBeVisible()
+ expect(container.querySelectorAll('.ms-GroupHeader-title')[0]).toHaveTextContent('Group1(2)')
+ expect(container.querySelectorAll('.ms-GroupHeader-title')[1]).toHaveTextContent('Group2(0)')
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + tableProps.rows!.length - 1)
+
+ fireEvent.click(getAllByText('Group1')[3].parentElement!)
+
+ expectAllGroupsToBeVisible()
+ expectAllItemsToBePresent()
+ })
+
+ it("Checks if empty groups are shown - search", () => {
+ const
+ { container, getAllByText, getAllByRole, getByTestId } = render(),
+ expectAllGroupsToBeVisible = () => {
+ expect(getAllByText('Group1')[0]).toBeVisible()
+ expect(getAllByText('Group2')[0]).toBeVisible()
+ },
+ expectAllItemsToBePresent = () => {
+ const [firstGroupHeader, secondGroupHeader] = container.querySelectorAll('.ms-GroupHeader-title')
+ expect(firstGroupHeader).toHaveTextContent('Group1(2)')
+ expect(secondGroupHeader).toHaveTextContent('Group2(1)')
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + tableProps.rows!.length)
+ }
+
+ fireEvent.click(getByTestId('groupby'))
+ fireEvent.click(getAllByText('Col2')[1]!)
+ fireEvent.click(container.querySelector('.ms-DetailsHeader-collapseButton')!)
+
+ expectAllGroupsToBeVisible()
+ expectAllItemsToBePresent()
+
+ fireEvent.change(getByTestId('search'), { target: { value: cell31 } })
+
+ expectAllGroupsToBeVisible()
+ expect(container.querySelectorAll('.ms-GroupHeader-title')[0]).toHaveTextContent('Group1(0)')
+ expect(container.querySelectorAll('.ms-GroupHeader-title')[1]).toHaveTextContent('Group2(1)')
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + filteredItem)
+
+ fireEvent.change(getByTestId('search'), { target: { value: '' } })
+
+ expectAllGroupsToBeVisible()
+ expectAllItemsToBePresent()
+ })
+
it('Does not render group by dropdown when groups are set but pagination is not', () => {
const { queryByTestId } = render()
expect(queryByTestId('groupby')).not.toBeInTheDocument()
@@ -1094,6 +1162,30 @@ describe('Table.tsx', () => {
expect(emitMock).toHaveBeenCalledTimes(1)
})
+ it('Checks if groups are correct when grouping by multiple times', () => {
+ const
+ { container, getAllByText, getByTestId, getAllByRole } = render(),
+ groupBy = (col: string) => {
+ fireEvent.click(getByTestId('groupby'))
+ fireEvent.click(getAllByText(col)[1]!)
+ fireEvent.click(container.querySelector('.ms-DetailsHeader-collapseButton')!)
+ }
+
+ groupBy('Col1')
+
+ expect(getAllByRole('row')).toHaveLength(headerRow + 2 * tableProps.rows!.length)
+ const groupHeaders = container.querySelectorAll('.ms-GroupHeader-title')
+ expect(groupHeaders[0]).toHaveTextContent(`${cell21}(1)`)
+ expect(groupHeaders[1]).toHaveTextContent(`${cell11}(1)`)
+ expect(groupHeaders[2]).toHaveTextContent(`${cell31}(1)`)
+
+ groupBy('Col2')
+
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + tableProps.rows!.length)
+ expect(container.querySelectorAll('.ms-GroupHeader-title')[0]).toHaveTextContent(`${'Group1'}(2)`)
+ expect(container.querySelectorAll('.ms-GroupHeader-title')[1]).toHaveTextContent(`${'Group2'}(1)`)
+ })
+
it('Renders alphabetically sorted group by list - strings', () => {
const { container, getAllByText, getByTestId } = render()
@@ -1202,7 +1294,7 @@ describe('Table.tsx', () => {
expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + tableProps.rows!.length)
fireEvent.change(getByTestId('search'), { target: { value: cell21 } })
- expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRow + filteredItem)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + filteredItem)
})
it('Filters grouped list - single option', () => {
@@ -1215,7 +1307,7 @@ describe('Table.tsx', () => {
expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + tableProps.rows!.length)
fireEvent.click(container.querySelector('.ms-DetailsHeader-filterChevron')!)
fireEvent.click(getAllByText('Group2')[2].parentElement!)
- expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRow + filteredItem)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + filteredItem)
})
it('Filters grouped list - multiple options', () => {
@@ -1231,10 +1323,66 @@ describe('Table.tsx', () => {
fireEvent.click(getAllByText('Group2')[0].parentElement!)
expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + tableProps.rows!.length)
})
+
+ it('Checks if group name is correct when grouped by column width time data', () => {
+ tableProps = {
+ ...tableProps,
+ groupable: true,
+ columns: [
+ { name: 'colname1', label: 'Col1' },
+ { name: 'colname2', label: 'Col2', data_type: 'time' },
+ ],
+ rows: [
+ { name: 'rowname1', cells: [cell11, '1655927271'] },
+ { name: 'rowname2', cells: [cell21, '1655927271000'] },
+ ]
+ }
+ const { container, getAllByText, getByTestId, getAllByRole } = render()
+
+ fireEvent.click(getByTestId('groupby'))
+ fireEvent.click(getAllByText('Col2')[1]!)
+ fireEvent.click(container.querySelector('.ms-DetailsHeader-collapseButton')!)
+
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + tableProps.rows!.length)
+ expect(container.querySelectorAll('.ms-GroupHeader-title')[0]).toHaveTextContent('1/20/1970, 4:58:47 AM(1)')
+ expect(container.querySelectorAll('.ms-GroupHeader-title')[1]).toHaveTextContent('6/22/2022, 8:47:51 PM(1)')
+ })
+
+ it('Checks if name of empty group is correct when grouped by column width time data', () => {
+ tableProps = {
+ ...tableProps,
+ groupable: true,
+ columns: [
+ { name: 'colname1', label: 'Col1' },
+ { name: 'colname2', label: 'Col2', data_type: 'time', filterable: true },
+ ],
+ rows: [
+ { name: 'rowname1', cells: [cell11, '1655927271'] },
+ { name: 'rowname2', cells: [cell21, '1655927271000'] },
+ ]
+ }
+ const { container, getAllByText, getByTestId, getAllByRole } = render()
+
+ fireEvent.click(getByTestId('groupby'))
+ fireEvent.click(getAllByText('Col2')[1]!)
+ fireEvent.click(container.querySelector('.ms-DetailsHeader-collapseButton')!)
+
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + tableProps.rows!.length)
+
+ fireEvent.click(container.querySelector('.ms-DetailsHeader-filterChevron')!)
+ fireEvent.click(getAllByText('6/22/2022, 8:47:51 PM')[2].parentElement!)
+
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + filteredItem)
+ expect(container.querySelectorAll('.ms-GroupHeader-title')[0]).toHaveTextContent('1/20/1970, 4:58:47 AM(0)')
+ expect(container.querySelectorAll('.ms-GroupHeader-title')[1]).toHaveTextContent('6/22/2022, 8:47:51 PM(1)')
+ })
})
describe('Groups', () => {
- const items = 3
+ const
+ items = 3,
+ firstGroupLabel = 'GroupA',
+ secondGroupLabel = 'GroupB'
beforeEach(() => {
tableProps = {
name,
@@ -1244,7 +1392,7 @@ describe('Table.tsx', () => {
],
groups: [
{
- label: "GroupA",
+ label: firstGroupLabel,
rows: [
{ name: 'rowname1', cells: [cell11, 'Group2'] },
{ name: 'rowname2', cells: [cell21, 'Group1'] },
@@ -1252,7 +1400,7 @@ describe('Table.tsx', () => {
collapsed: false
},
{
- label: "GroupB",
+ label: secondGroupLabel,
rows: [
{ name: 'rowname3', cells: [cell31, 'Group2'] }
],
@@ -1273,7 +1421,7 @@ describe('Table.tsx', () => {
const { getByTestId, getAllByRole } = render()
fireEvent.change(getByTestId('search'), { target: { value: cell21 } })
- expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRow + filteredItem)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + filteredItem)
})
it('Sorts rows inside the group of the grouped list', () => {
@@ -1290,7 +1438,7 @@ describe('Table.tsx', () => {
fireEvent.click(container.querySelector('.ms-DetailsHeader-filterChevron')!)
fireEvent.click(getAllByText('Group1')[1].parentElement!)
- expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRow + filteredItem)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + filteredItem)
})
it('Filters grouped list - multiple options', () => {
@@ -1346,7 +1494,7 @@ describe('Table.tsx', () => {
fireEvent.click(container.querySelector('.ms-DetailsHeader-filterChevron')!)
fireEvent.click(getAllByText('Group1')[0].parentElement!)
- expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRow)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount)
fireEvent.click(getAllByText('Group1')[0].parentElement!)
expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + filteredItem)
@@ -1356,7 +1504,7 @@ describe('Table.tsx', () => {
const { getByTestId, getAllByRole } = render()
fireEvent.change(getByTestId('search'), { target: { value: cell21 } })
- expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRow + filteredItem)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + filteredItem)
})
it('Checks if expanded state is preserved after search', () => {
@@ -1372,7 +1520,7 @@ describe('Table.tsx', () => {
fireEvent.click(container.querySelector('.ms-DetailsHeader-collapseButton')!)
fireEvent.change(getByTestId('search'), { target: { value: cell21 } })
- expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRow)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount)
})
it('Checks if collapsed state is preserved after search', () => {
@@ -1399,10 +1547,71 @@ describe('Table.tsx', () => {
fireEvent.click(container.querySelector('.ms-DetailsHeader-collapseButton')!)
fireEvent.change(getByTestId('search'), { target: { value: cell31 } })
- fireEvent.click(container.querySelector('.ms-GroupHeader-expand')!)
+ fireEvent.click(container.querySelectorAll('.ms-GroupHeader-expand')[1]!)
fireEvent.change(getByTestId('search'), { target: { value: '' } })
expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + filteredItem)
})
+
+ it("Checks if empty custom groups are shown - filter", () => {
+ const
+ { container, getByText, getAllByText, getAllByRole } = render(),
+ groupHeaders = container.querySelectorAll('.ms-GroupHeader-title'),
+ expectAllGroupsToBeVisible = () => {
+ expect(getByText(firstGroupLabel)).toBeVisible()
+ expect(getByText(secondGroupLabel)).toBeVisible()
+ },
+ expectAllItemsToBePresent = () => {
+ expect(groupHeaders[0]).toHaveTextContent(`${firstGroupLabel}(2)`)
+ expect(groupHeaders[1]).toHaveTextContent(`${secondGroupLabel}(1)`)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + items)
+ }
+
+ expectAllGroupsToBeVisible()
+ expectAllItemsToBePresent()
+
+ fireEvent.click(container.querySelector('.ms-DetailsHeader-filterChevron')!)
+ fireEvent.click(getAllByText('Group1')[1].parentElement!)
+
+ expectAllGroupsToBeVisible()
+ expect(groupHeaders[0]).toHaveTextContent(`${firstGroupLabel}(1)`)
+ expect(groupHeaders[1]).toHaveTextContent(`${secondGroupLabel}(0)`)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + filteredItem)
+
+ fireEvent.click(getAllByText('Group1')[1].parentElement!)
+
+ expectAllGroupsToBeVisible()
+ expectAllItemsToBePresent()
+ })
+
+ it("Checks if empty custom groups are shown - search", () => {
+ const
+ { container, getByText, getByTestId, getAllByRole } = render(),
+ groupHeaders = container.querySelectorAll('.ms-GroupHeader-title'),
+ expectAllGroupsToBeVisible = () => {
+ expect(getByText(firstGroupLabel)).toBeVisible()
+ expect(getByText(secondGroupLabel)).toBeVisible()
+ },
+ expectAllItemsToBePresent = () => {
+ expect(groupHeaders[0]).toHaveTextContent(`${firstGroupLabel}(2)`)
+ expect(groupHeaders[1]).toHaveTextContent(`${secondGroupLabel}(1)`)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + items)
+ }
+
+ expectAllGroupsToBeVisible()
+ expectAllItemsToBePresent()
+
+ fireEvent.change(getByTestId('search'), { target: { value: cell31 } })
+
+ expectAllGroupsToBeVisible()
+ expect(groupHeaders[0]).toHaveTextContent(`${firstGroupLabel}(0)`)
+ expect(groupHeaders[1]).toHaveTextContent(`${secondGroupLabel}(1)`)
+ expect(getAllByRole('row')).toHaveLength(headerRow + groupHeaderRowsCount + filteredItem)
+
+ fireEvent.change(getByTestId('search'), { target: { value: '' } })
+
+ expectAllGroupsToBeVisible()
+ expectAllItemsToBePresent()
+ })
})
describe('Reset', () => {
diff --git a/ui/src/table.tsx b/ui/src/table.tsx
index 86b97093993..20c5f08938c 100644
--- a/ui/src/table.tsx
+++ b/ui/src/table.tsx
@@ -270,6 +270,10 @@ const
: b > a ? 1 : -1
},
formatNum = (num: U) => num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","),
+ valueToDateString = (value: S) => {
+ const epoch = Number(value)
+ return new Date(isNaN(epoch) ? value : epoch).toLocaleString()
+ },
toCSV = (data: unknown[][]): S => data.map(row => {
const line = JSON.stringify(row)
return line.substr(1, line.length - 2)
@@ -317,7 +321,7 @@ const
menuFilters.map(({ key, data, checked }) => (
)
- if (col.dataType === 'time') {
- const epoch = Number(v)
- v = new Date(isNaN(epoch) ? v : epoch).toLocaleString()
- }
+ if (col.dataType === 'time') v = valueToDateString(v)
if (col.key === primaryColumnKey) {
const onClick = () => {
@@ -565,7 +566,8 @@ const
onToggleCollapseAll,
onRenderHeader: onRenderGroupHeader,
headerProps: { onToggleCollapse },
- isAllGroupsCollapsed: m.groups?.every(({ collapsed = true }) => collapsed)
+ isAllGroupsCollapsed: m.groups?.every(({ collapsed = true }) => collapsed),
+ showEmptyGroups: true
}}
getGroupHeight={getGroupHeight}
selection={selection}
@@ -727,19 +729,29 @@ export const
const expandedRef = expandedRefs[key]
return expandedRef === undefined || expandedRef
},
+ groupNames = React.useMemo(() => {
+ return m.groups
+ ? m.groups.reduce((acc, { label }) => acc.add(label), new Set())
+ : groupByKey !== '*'
+ ? (items as Dict[]).reduce((acc, item) => acc.add(item[groupByKey]), new Set())
+ : new Set()
+ }, [m.groups, groupByKey, items]),
makeGroups = React.useCallback((groupByKey: S, filteredItems: (Fluent.IObjectWithKey & Dict)[]) => {
+ const allGroups = [...groupNames].reduce((acc, groupName) => {
+ acc[groupName] = { key: groupName, name: groupName, startIndex: 0, count: 0, isCollapsed: getIsCollapsed(groupName, expandedRefs.current) }
+ return acc
+ }, {} as Dict)
let
groups: Fluent.IGroup[],
groupedBy: Dict = []
if (m.groups) {
- groups = filteredItems.reduce((acc, { group }, idx) => {
- const prevGroup = acc[acc.length - 1]
- prevGroup?.key === group
- ? prevGroup.count++
- : acc.push({ key: group, name: group, startIndex: idx, count: 1, isCollapsed: getIsCollapsed(group, expandedRefs.current) })
- return acc
- }, [] as Fluent.IGroup[])
+ filteredItems.forEach(({ group }, idx) => {
+ allGroups[group].count === 0
+ ? allGroups[group] = { ...allGroups[group], startIndex: idx, count: 1 }
+ : allGroups[group].count++
+ })
+ groups = Object.values(allGroups)
} else {
let prevSum = 0
groupedBy = groupByF(filteredItems, groupByKey)
@@ -747,15 +759,17 @@ export const
groupedByKeys = Object.keys(groupedBy),
groupByColType = m.columns.find(c => c.name === groupByKey)?.data_type
- groups = groupedByKeys.map((key, i) => {
+ groupedByKeys.forEach((key, i) => {
if (i !== 0) {
const prevKey = groupedByKeys[i - 1]
prevSum += groupedBy[prevKey].length
}
+ allGroups[key] = { key, name: key, startIndex: prevSum, count: groupedBy[key].length, isCollapsed: getIsCollapsed(key, expandedRefs.current) }
+ })
+
+ if (groupByColType === 'time') Object.keys(allGroups).forEach(key => { allGroups[key].name = valueToDateString(key) })
- const name = groupByColType === 'time' ? new Date(key).toLocaleString() : key
- return { key, name, startIndex: prevSum, count: groupedBy[key].length, isCollapsed: getIsCollapsed(key, expandedRefs.current) }
- }).sort(({ name: name1 }, { name: name2 }) => {
+ groups = Object.values(allGroups).sort(({ name: name1 }, { name: name2 }) => {
const numName1 = Number(name1), numName2 = Number(name2)
if (!isNaN(numName1) && !isNaN(numName2)) return numName1 - numName2
@@ -765,9 +779,8 @@ export const
return name2 < name1 ? 1 : -1
})
}
-
return { groupedBy, groups }
- }, [m.columns, m.groups]),
+ }, [groupNames, m.columns, m.groups]),
initGroups = React.useCallback(() => {
setGroupByKey(groupByKey => {
setFilteredItems(filteredItems => {
@@ -975,6 +988,8 @@ export const
if (!m.pagination) reset()
}, [items])
+ useUpdateOnlyEffect(() => { initGroups() }, [groupNames, initGroups])
+
React.useEffect(() => {
if (m.groups) {
expandedRefs.current = m.groups?.reduce((acc, { label, collapsed = true }) => {
diff --git a/website/docs/examples/assets/table-groups.png b/website/docs/examples/assets/table-groups.png
index 7c64ba9c2b2..ce3f5e10605 100644
Binary files a/website/docs/examples/assets/table-groups.png and b/website/docs/examples/assets/table-groups.png differ
diff --git a/website/widgets/form/table.md b/website/widgets/form/table.md
index 24301a429a6..441493871fe 100644
--- a/website/widgets/form/table.md
+++ b/website/widgets/form/table.md
@@ -393,7 +393,8 @@ q.page['example'] = ui.form_card(box='1 1 3 4', items=[
ui.table_row(name='row3', cells=['Task3', 'High']),
ui.table_row(name='row4', cells=['Task4', 'Low']),
ui.table_row(name='row5', cells=['Task5', 'Very High'])
- ])
+ ]),
+ ui.table_group("Assigned to Mary", []),
])
])
```