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", []), ]) ]) ```