From 64561bad311d703f1a854c19cabe19316586a2f0 Mon Sep 17 00:00:00 2001 From: Joseph Ojoko Date: Fri, 12 Jan 2024 19:03:18 +0100 Subject: [PATCH] feat: table grouping, provide table defaults, adjust examples --- .storybook/app.ts | 17 +- example/cypress/e2e/data-table.cy.ts | 10 +- example/src/main.ts | 5 + example/src/views/DataTableView.vue | 60 +- src/components/index.ts | 8 +- src/components/tables/DataTable.spec.ts | 38 +- src/components/tables/DataTable.stories.ts | 68 +- src/components/tables/DataTable.vue | 831 +++++++++++---------- src/components/tables/TableHead.vue | 378 ++++++++++ src/components/tables/TablePagination.vue | 6 +- src/composables/defaults/table.ts | 2 + 11 files changed, 988 insertions(+), 435 deletions(-) create mode 100644 src/components/tables/TableHead.vue diff --git a/.storybook/app.ts b/.storybook/app.ts index 1632a08d..acfcf0f5 100644 --- a/.storybook/app.ts +++ b/.storybook/app.ts @@ -1,16 +1,21 @@ -import { createApp } from 'vue'; +import { createApp, ref } from 'vue'; import { createRui } from '../src'; import * as Icons from '../src/icons'; const RuiPlugin = createRui({ theme: { icons: Object.values(Icons), - } -}) + }, + defaults: { + table: { + itemsPerPage: ref(10), + globalItemsPerPage: false, + limits: [5, 10, 15, 25, 50, 100, 200], + }, + }, +}); const app = createApp({ template: '
' }); -app - .use(RuiPlugin) - .mount(null); +app.use(RuiPlugin).mount(null); export const vueInstance = app; diff --git a/example/cypress/e2e/data-table.cy.ts b/example/cypress/e2e/data-table.cy.ts index 54c9fbf9..68239345 100644 --- a/example/cypress/e2e/data-table.cy.ts +++ b/example/cypress/e2e/data-table.cy.ts @@ -120,8 +120,8 @@ describe('DataTable', () => { cy.get('@buttons').eq(2).as('button2'); cy.get('@multiple') - .find('table tbody tr[hidden] div[data-cy=expanded-content]') - .should('exist'); + .find('table tbody tr div[data-cy=expanded-content]') + .should('not.exist'); cy.get('@button1').click(); @@ -163,8 +163,8 @@ describe('DataTable', () => { cy.get('@buttons').eq(2).as('button2'); cy.get('@single') - .find('table tbody tr[hidden] div[data-cy=expanded-content]') - .should('exist'); + .find('table tbody tr div[data-cy=expanded-content]') + .should('not.exist'); cy.get('@button1').click(); @@ -196,7 +196,7 @@ describe('DataTable', () => { }); }); - it.only('checks for data tables with sticky header', () => { + it('checks for data tables with sticky header', () => { cy.get('div[data-cy="table-expandable-0"]').as('sticky'); cy.get('@sticky') diff --git a/example/src/main.ts b/example/src/main.ts index 64d1badd..44799f1f 100644 --- a/example/src/main.ts +++ b/example/src/main.ts @@ -20,7 +20,9 @@ import { RiArrowUpSLine, RiCheckboxCircleLine, RiCloseFill, + RiDeleteBinLine, RiErrorWarningLine, + RiFileCopyLine, RiInformationLine, RiMacbookLine, RiMoonLine, @@ -60,12 +62,15 @@ const RuiPlugin = createRui({ RiArrowUpSLine, RiArrowDownSLine, RiArrowDownCircleLine, + RiDeleteBinLine, + RiFileCopyLine, ], }, defaults: { table: { itemsPerPage, globalItemsPerPage: false, + limits: [5, 10, 15, 25, 50, 100, 200], }, }, }); diff --git a/example/src/views/DataTableView.vue b/example/src/views/DataTableView.vue index 6fb417e1..caef6008 100644 --- a/example/src/views/DataTableView.vue +++ b/example/src/views/DataTableView.vue @@ -110,6 +110,7 @@ const fixedColumns: DataTableColumn[] = [ { key: 'name', label: 'Full name', + sortable: true, }, { key: 'username', @@ -164,7 +165,7 @@ const fixedRows: ExtendedUser[] = [ { id: 3, name: 'Clementine Bauch', - username: 'Samantha', + username: 'Kamren', email: 'Nathan@yesenia.net', website: 'ramiro.info', 'address.street': 'Douglas Extension', @@ -180,14 +181,50 @@ const fixedRows: ExtendedUser[] = [ 'address.city': 'Wisokyburgh', }, { - id: 9, + id: 19, name: 'Glenna Reichert', - username: 'Delphine', + username: 'Kamren', email: 'Chaim_McDermott@dana.io', website: 'conrad.com', 'address.street': 'Dayna Park', 'address.city': 'Bartholomebury', }, + { + id: 15, + name: 'Chelsey Dietrich', + username: 'Kamren', + email: 'Lucio_Hettinger@annie.ca', + website: 'demarco.info', + 'address.street': 'Skiles Walks', + 'address.city': 'Roscoeview', + }, + { + id: 110, + name: 'Clementina DuBuque', + username: 'Moriah.Stanton', + email: 'Rey.Padberg@karina.biz', + website: 'ambrose.net', + 'address.street': 'Kattie Turnpike', + 'address.city': 'Lebsackbury', + }, + { + id: 13, + name: 'Clementine Bauch', + username: 'Kamren', + email: 'Nathan@yesenia.net', + website: 'ramiro.info', + 'address.street': 'Douglas Extension', + 'address.city': 'McKenziehaven', + }, + { + id: 12, + name: 'Ervin Howell', + username: 'Antonette', + email: 'Shanna@melissa.tv', + website: 'anastasia.net', + 'address.street': 'Victor Plains', + 'address.city': 'Wisokyburgh', + }, ]; const emptyTables = ref< @@ -256,6 +293,8 @@ const emptyTables = ref< pagination: { limit: 5, page: 1, total: 5 }, stickyHeader: true, stickyOffset: 72, + group: ['username'], + collapsed: [], }, }, ]); @@ -305,6 +344,8 @@ const expandableTables = ref< sort: [{ column: 'name', direction: 'asc' }], pagination: { limit: 5, page: 1, total: 5 }, expanded: [], + stickyHeader: true, + stickyOffset: 72, }, }, ]); @@ -510,6 +551,8 @@ const apiDatatables = ref< search: '', sort: { column: 'name', direction: 'asc' }, pagination: { limit: 5, page: 1, total: 0 }, + group: ['username'], + collapsed: [], }, }, { @@ -733,8 +776,11 @@ const toggleRow = (row: any, expanded: any[] | undefined) => { v-model="table.modelValue" v-model:pagination="table.pagination" v-model:sort="table.sort" + v-model:group="table.group" + v-model:collapsed="table.collapsed" :data-cy="`table-empty-${i}`" > + + @@ -894,6 +945,9 @@ const toggleRow = (row: any, expanded: any[] | undefined) => { +
diff --git a/src/components/index.ts b/src/components/index.ts index e5e899be..fd2958b2 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -85,9 +85,13 @@ import { } from '@/components/forms/select/SimpleSelect.vue'; import { type TableColumn as DataTableColumn, + type GroupKeys as DataTableGroupKeys, + type SortColumn as DataTableSortColumn, + type TableSortData as DataTableSortData, +} from '@/components/tables/TableHead.vue'; +import { type TableOptions as DataTableOptions, type Props as DataTableProps, - type SortColumn as DataTableSortColumn, default as RuiDataTable, } from '@/components/tables/DataTable.vue'; import { @@ -195,4 +199,6 @@ export { DividerProps, TextAreaProps, ExpandButtonProps, + DataTableSortData, + DataTableGroupKeys, }; diff --git a/src/components/tables/DataTable.spec.ts b/src/components/tables/DataTable.spec.ts index ecda9b42..03b36c38 100644 --- a/src/components/tables/DataTable.spec.ts +++ b/src/components/tables/DataTable.spec.ts @@ -1,7 +1,8 @@ /* eslint-disable max-lines */ import { describe, expect, it } from 'vitest'; import { type ComponentMountingOptions, mount } from '@vue/test-utils'; -import DataTable, { type TableColumn } from '@/components/tables/DataTable.vue'; +import { type TableColumn } from '@/components/tables/TableHead.vue'; +import DataTable from '@/components/tables/DataTable.vue'; import TablePagination from '@/components/tables/TablePagination.vue'; import { RuiSimpleSelect } from '~/src'; @@ -19,7 +20,9 @@ const createWrapper = ( ...options, global: { provide: { - [TableSymbol.valueOf()]: createTableDefaults(), + [TableSymbol.valueOf()]: createTableDefaults({ + limits: [5, 10, 15, 25, 50, 100, 200], + }), }, }, }); @@ -123,9 +126,9 @@ describe('DataTable', () => { expect( wrapper - .find('tbody tr[hidden]:nth-child(2) div[data-cy=expanded-content]') + .find('tbody tr:nth-child(2) div[data-cy=expanded-content]') .exists(), - ).toBeTruthy(); + ).toBeFalsy(); await wrapper .find('tbody tr:nth-child(1) button[class*=_tr__expander_button]') @@ -139,9 +142,9 @@ describe('DataTable', () => { expect( wrapper - .find('tbody tr[hidden]:nth-child(2) div[data-cy=expanded-content]') + .find('tbody tr:nth-child(2) div[data-cy=expanded-content]') .exists(), - ).toBeFalsy(); + ).toBeTruthy(); }); it('multiple expand toggles correctly', async () => { @@ -167,7 +170,7 @@ describe('DataTable', () => { wrapper .find('tbody tr[hidden]:nth-child(2) div[data-cy=expanded-content]') .exists(), - ).toBeTruthy(); + ).toBeFalsy(); await wrapper .find('tbody tr:nth-child(1) button[class*=_tr__expander_button]') @@ -183,7 +186,7 @@ describe('DataTable', () => { expect( wrapper - .find('tbody tr:not(hidden):nth-child(2) div[data-cy=expanded-content]') + .find('tbody tr:nth-child(2) div[data-cy=expanded-content]') .exists(), ).toBeTruthy(); @@ -201,7 +204,7 @@ describe('DataTable', () => { expect( wrapper - .find('tbody tr:not(hidden):nth-child(4) div[data-cy=expanded-content]') + .find('tbody tr:nth-child(4) div[data-cy=expanded-content]') .exists(), ).toBeTruthy(); }); @@ -228,9 +231,9 @@ describe('DataTable', () => { expect( wrapper - .find('tbody tr[hidden]:nth-child(2) div[data-cy=expanded-content]') + .find('tbody tr:nth-child(2) div[data-cy=expanded-content]') .exists(), - ).toBeTruthy(); + ).toBeFalsy(); await wrapper .find('tbody tr:nth-child(1) button[class*=_tr__expander_button]') @@ -246,7 +249,7 @@ describe('DataTable', () => { expect( wrapper - .find('tbody tr:not(hidden):nth-child(2) div[data-cy=expanded-content]') + .find('tbody tr:nth-child(2) div[data-cy=expanded-content]') .exists(), ).toBeTruthy(); @@ -258,9 +261,9 @@ describe('DataTable', () => { expect( wrapper - .find('tbody tr[hidden]:nth-child(2) div[data-cy=expanded-content]') + .find('tbody tr:nth-child(2) div[data-cy=expanded-content]') .exists(), - ).toBeTruthy(); + ).toBeFalsy(); await wrapper .find('tbody tr:nth-child(1) button[class*=_tr__expander_button]') @@ -286,9 +289,9 @@ describe('DataTable', () => { expect( wrapper - .find('tbody tr:not(hidden):nth-child(4) div[data-cy=expanded-content]') + .find('tbody tr:nth-child(4) div[data-cy=expanded-content]') .exists(), - ).toBeTruthy(); + ).toBeFalsy(); }); it('sticky header behaves as expected', async () => { @@ -327,6 +330,7 @@ describe('DataTable', () => { [TableSymbol.valueOf()]: createTableDefaults({ itemsPerPage, globalItemsPerPage: true, + limits: [5, 10, 15, 25, 50, 100, 200], }), }, }, @@ -373,6 +377,7 @@ describe('DataTable', () => { [TableSymbol.valueOf()]: createTableDefaults({ itemsPerPage, globalItemsPerPage: true, + limits: [5, 10, 15, 25, 50, 100, 200], }), }, }, @@ -423,6 +428,7 @@ describe('DataTable', () => { provide: { [TableSymbol.valueOf()]: createTableDefaults({ itemsPerPage, + limits: [5, 10, 15, 25, 50, 100, 200], }), }, }, diff --git a/src/components/tables/DataTable.stories.ts b/src/components/tables/DataTable.stories.ts index 503fb3f5..4528af5d 100644 --- a/src/components/tables/DataTable.stories.ts +++ b/src/components/tables/DataTable.stories.ts @@ -4,10 +4,8 @@ import Button from '@/components/buttons/button/Button.vue'; import TextField from '@/components/forms/text-field/TextField.vue'; import Icon from '@/components/icons/Icon.vue'; import Card from '@/components/cards/Card.vue'; -import DataTable, { - type TableColumn, - type Props as TableProps, -} from './DataTable.vue'; +import DataTable, { type Props as TableProps } from './DataTable.vue'; +import { type TableColumn } from './TableHead.vue'; import type { Meta, StoryFn, StoryObj } from '@storybook/vue3'; type User = { @@ -56,8 +54,42 @@ const render: StoryFn = (args) => ({ args.search = val; }, }); + const expanded = computed({ + get() { + return args.expanded; + }, + set(val) { + args.expanded = val; + }, + }); + const group = computed({ + get() { + return args.group; + }, + set(val) { + args.group = val; + }, + }); + const collapsed = computed({ + get() { + return args.collapsed; + }, + set(val) { + args.collapsed = val; + }, + }); - return { args, modelValue, pagination, search, sort, objectOmit }; + return { + args, + modelValue, + pagination, + search, + sort, + expanded, + group, + collapsed, + objectOmit, + }; }, template: `
@@ -81,6 +113,8 @@ const render: StoryFn = (args) => ({ 'pagination', 'sort', 'expanded', + 'group', + 'collapsed', ]) " v-model="modelValue" @@ -88,6 +122,8 @@ const render: StoryFn = (args) => ({ v-model:sort="sort" :search="search" v-model:expanded="args.expanded" + v-model:group="args.group" + v-model:collapsed="args.collapsed" :row-attr="args.rowAttr" :rows="args.rows" > @@ -106,6 +142,8 @@ const render: StoryFn = (args) => ({ 'pagination', 'sort', 'expanded', + 'group', + 'collapsed', ]) " :row-attr="args.rowAttr" @@ -205,6 +243,7 @@ const columns: TableColumn[] = [ key: 'email', label: 'Email address', sortable: true, + align: 'center', }, { key: 'role', @@ -235,6 +274,7 @@ const meta: Meta = { loading: false, dense: false, outlined: false, + striped: false, }, parameters: { docs: { @@ -253,6 +293,7 @@ const meta: Meta = { '`item.${column.key.toString()}`', 'body.append', 'item.expand', + 'group.header', 'expanded-item', ], }, @@ -467,4 +508,21 @@ export const StickyHeader: Story = { }, }; +export const Grouped: Story = { + args: { + rows: data, + modelValue: [], + cols: columns, + outlined: true, + pagination: { limit: 5, page: 1, total: 50 }, + sort: [ + { column: 'name', direction: 'asc' }, + { column: 'email', direction: 'asc' }, + ], + expanded: [], + collapsed: [], + group: 'name', + }, +}; + export default meta; diff --git a/src/components/tables/DataTable.vue b/src/components/tables/DataTable.vue index fc97b838..d93b8bce 100644 --- a/src/components/tables/DataTable.vue +++ b/src/components/tables/DataTable.vue @@ -4,78 +4,36 @@ generic="T extends object, IdType extends keyof T = keyof T" > import { type Ref } from 'vue'; -import Button from '@/components/buttons/button/Button.vue'; import Checkbox from '@/components/forms/checkbox/Checkbox.vue'; +import Button from '@/components/buttons/button/Button.vue'; import Icon from '@/components/icons/Icon.vue'; -import Progress from '@/components/progress/Progress.vue'; -import RuiBadge from '@/components/overlays/badge/Badge.vue'; +import Tooltip from '@/components/overlays/tooltip/Tooltip.vue'; import ExpandButton from '@/components/tables/ExpandButton.vue'; import TablePagination, { type TablePaginationData, } from './TablePagination.vue'; - -/** - * Represents a sortable column name for a given type. - * The column name must be a key of the passed data object type. - * @template T - The type of the data in the column. - */ -export type SortableColumnName = keyof T; - -/** - * Represents the name of a column in a dataset. - * - * The `ColumnName` type can either be a `SortableColumnName` or a string. - * - * @typeparam T - The type of the dataset containing the column name. - */ -export type ColumnName = SortableColumnName | string; - -export interface BaseTableColumn { - direction?: 'asc' | 'desc'; - align?: 'start' | 'center' | 'end'; - class?: string; - cellClass?: string; - - [key: string]: any; -} - -/** - * Represents a sortable table column. - * This is used to ensure that when using sortable with a true value, - * the key matches to an actual property of the object passed. - * - * @template T - The type of data in the table column. - */ -export interface SortableTableColumn extends BaseTableColumn { - key: SortableColumnName; - sortable: true; -} - -/** - * An interface representing a column in a table that cannot be sorted. - * This can be mapped to an actual property of the object or to a virtual column. - * - * @typeparam T - The type of data in the table. - */ -export interface NoneSortableTableColumn extends BaseTableColumn { - key: string | SortableColumnName; - sortable?: false; -} - -export type TableColumn = - | SortableTableColumn - | NoneSortableTableColumn; - -export interface SortColumn { - column?: SortableColumnName; - direction: 'asc' | 'desc'; -} +import TableHead, { + type GroupData, + type GroupKeys, + type NoneSortableTableColumn, + type SortColumn, + type TableColumn, + type TableRowKey, + type TableRowKeyData, + type TableSortData, +} from './TableHead.vue'; export interface TableOptions { pagination?: TablePaginationData; sort?: SortColumn | SortColumn[]; } +export type GroupedTableRow = T & + Partial<{ + groupVal: string; + group: Partial; + }>; + export interface Props { /** * list of items for each row @@ -118,7 +76,7 @@ export interface Props { * multi columns sort * @example v-model:sort="[{ column: 'name', direction: 'asc' }]" */ - sort?: SortColumn | SortColumn[]; + sort?: TableSortData; /** * modifiers for specifying externally sorted tables * use this when api controls sorting @@ -137,7 +95,7 @@ export interface Props { /** * attribute to use from column definitions to display column titles */ - columnAttr?: string; + columnAttr?: keyof TableColumn; /** * flag to show a more or less spacious table */ @@ -179,7 +137,7 @@ export interface Props { */ singleExpand?: boolean; /** - * make expansion work like accordion + * make table head stick to top on scroll */ stickyHeader?: boolean; stickyOffset?: number; @@ -188,6 +146,15 @@ export interface Props { * When true, changing the items per page setting in one table will affect other tables. */ globalItemsPerPage?: boolean; + /** + * model for grouping column/columns data + * single column grouping + * @example v-model:group="'name'" + * multi columns grouping + * @example v-model:group="['name', 'country']" + */ + group?: TableRowKeyData; + collapsed?: T[]; } defineOptions({ @@ -205,32 +172,49 @@ const props = withDefaults(defineProps>(), { loading: false, dense: false, outlined: false, - striped: false, paginationModifiers: undefined, sortModifiers: undefined, - empty: () => ({ label: 'No item found' }), - hideDefaultFooter: false, + empty: () => ({ description: 'No item found' }), rounded: 'md', + hideDefaultFooter: false, + striped: false, expanded: undefined, singleExpand: false, stickyHeader: false, stickyOffset: 0, globalItemsPerPage: undefined, + group: undefined, + collapsed: undefined, + customGroupBy: undefined, }); const emit = defineEmits<{ (e: 'update:model-value', value?: T[IdType][]): void; (e: 'update:expanded', value: T[]): void; (e: 'update:pagination', value: TablePaginationData): void; - (e: 'update:sort', value?: SortColumn | SortColumn[]): void; + (e: 'update:sort', value?: TableSortData): void; (e: 'update:options', value: TableOptions): void; + (e: 'update:group', value?: GroupKeys): void; + (e: 'update:collapsed', value?: T[]): void; + (e: 'copy:group', value: GroupData): void; }>(); +const { stickyOffset, stickyHeader, collapsed } = toRefs(props); + const css = useCssModule(); -const { stickyOffset } = toRefs(props); -const { stick, table, tableScroller } = useStickyTableHeader(stickyOffset); +const slots = useSlots(); +const { stick, table, tableScroller } = useStickyTableHeader( + stickyOffset, + stickyHeader, +); const tableDefaults = useTable(); +const groupHeaderKey = 'group.header'; + +const headerSlots = computed(() => + Object.keys(slots).filter((slotName) => slotName.startsWith('header.')), +); + const globalItemsPerPageSettings = computed(() => { if (props.globalItemsPerPage !== undefined) { return props.globalItemsPerPage; @@ -238,8 +222,7 @@ const globalItemsPerPageSettings = computed(() => { return get(tableDefaults.globalItemsPerPage); }); -const getKeys = (t: T) => - Object.keys(t) as SortableColumnName[]; +const getKeys = (t: T) => Object.keys(t) as TableRowKey[]; /** * Prepare the columns from props or generate using first item in the list @@ -250,7 +233,7 @@ const columns = computed[]>(() => { getKeys(props.rows[0] ?? {}).map( (key) => ({ - key: key.toString(), + key, [props.columnAttr]: key.toString(), }) satisfies NoneSortableTableColumn, ); @@ -259,15 +242,28 @@ const columns = computed[]>(() => { return [ ...data, { - key: 'expand', + key: 'expand' as TableRowKey, sortable: false, - }, + class: 'w-16', + cellClass: '!py-0 w-16', + align: 'end', + } satisfies NoneSortableTableColumn, ]; } - return data; + const groupByKeys = get(groupKeys); + + if (groupByKeys.length === 0) { + return data; + } + + return data.filter( + (column) => !groupByKeys.includes(column.key as TableRowKey), + ); }); +const itemsLength = ref(0); + const selectedData = computed({ get() { return props.modelValue; @@ -279,9 +275,8 @@ const selectedData = computed({ const rowIdentifier = computed(() => props.rowAttr); -const expandable = computed(() => props.expanded && slots['expanded-item']); - const internalPaginationState: Ref = ref(); +const collapsedRows: Ref = ref([]); const pagination = computed(() => props.pagination); @@ -289,6 +284,12 @@ watchImmediate(pagination, (pagination) => { set(internalPaginationState, pagination); }); +watchImmediate(collapsed, (value) => { + set(collapsedRows, value ?? []); +}); + +const expandable = computed(() => props.expanded && slots['expanded-item']); + /** * Keeps the global items per page in sync with the internal state. */ @@ -318,14 +319,18 @@ const paginationData: Ref = computed({ const paginated = get(internalPaginationState); if (!paginated) { return { - total: get(searchData).length, + total: get(itemsLength), limit: props.itemsPerPage, page: 1, }; } + if (props.paginationModifiers?.external) { + return paginated; + } + return { - total: get(searchData).length, + total: get(itemsLength), limit: paginated.limit, page: paginated.page, limits: paginated.limits, @@ -359,7 +364,7 @@ const sortData = computed({ * for easily checking if a column is sorted instead of looping through the array */ const sortedMap = computed(() => { - const mapped: Partial, SortColumn>> = {}; + const mapped: Partial, SortColumn>> = {}; const sortBy = get(sortData); if (!sortBy) { return mapped; @@ -391,7 +396,9 @@ const visibleIdentifiers = computed(() => { return []; } - return get(filtered)?.map((row) => row[selectBy]) ?? []; + return get(filtered) + .filter((row) => row[selectBy] !== groupHeaderKey) + .map((row) => row[selectBy]); }); /** @@ -428,7 +435,7 @@ const searchData = computed(() => { /** * sort the search results */ -const sorted = computed(() => { +const sorted: ComputedRef = computed(() => { const sortBy = get(sortData); const data = [...get(searchData)]; if (!sortBy || props.sortModifiers?.external) { @@ -471,11 +478,85 @@ const sorted = computed(() => { return data; }); +const groupKeys: ComputedRef[]> = computed(() => { + const groupBy = props.group; + + if (!groupBy) { + // no grouping + return []; + } + + if (!Array.isArray(groupBy)) { + // currently only supports a single grouping + // only the first item in the array is used + return [groupBy]; + } + + return groupBy; +}); + +const groupKey = computed(() => get(groupKeys).join(':')); + +const isGrouped = computed(() => !!get(groupKey)); + /** - * comprises search, sorted and paginated data + * comprises search, sorted paginated, and grouped data */ -const filtered = computed(() => { +const mappedGroups: ComputedRef[]>> = + computed(() => { + if (!get(isGrouped)) { + // no grouping + return {}; + } + + const result = get(sorted); + const identifier = props.rowAttr; + + return result.reduce((acc: Record, row) => { + if (!isDefined(row[identifier]) || row[identifier] === '') { + return acc; + } + + const group = getRowGroup(row); + const groupVal = Object.values(group).filter(isDefined).join(','); + if (!acc[groupVal]) { + acc[groupVal] = [ + { + [identifier]: groupHeaderKey, + group, + groupVal, + } as GroupedTableRow, + ]; + } + + acc[groupVal].push(row); + + return acc; + }, {}); + }); + +/** + * comprises search, sorted paginated, and grouped data + */ +const grouped: ComputedRef[]> = computed(() => { const result = get(sorted); + const groupByKey = get(groupKey); + + if (!groupByKey) { + // no grouping + return result; + } + + return Object.values(get(mappedGroups)) + .flatMap((grouped) => grouped) + .filter((row) => !isHiddenRow(row)); +}); + +/** + * comprises search, sorted and paginated data + */ +const filtered: ComputedRef[]> = computed(() => { + const result = get(grouped); const paginated = get(paginationData); const limit = paginated.limit; @@ -488,10 +569,6 @@ const filtered = computed(() => { return result; }); -const filteredMap = computed(() => - get(filtered).map((row) => row[props.rowAttr]), -); - const indeterminate = computed(() => { const selectedRows = get(selectedData); if (!selectedRows) { @@ -511,9 +588,9 @@ const colspan = computed(() => { return columnLength; }); -const isSortedBy = (key: ColumnName) => key in get(sortedMap); +const isSortedBy = (key: TableRowKey) => key in get(sortedMap); -const getSortIndex = (key: ColumnName) => { +const getSortIndex = (key: TableRowKey) => { const sortBy = get(sortData); if (!sortBy || !Array.isArray(sortBy) || !isSortedBy(key)) { @@ -562,6 +639,71 @@ const onToggleExpand = (row: T) => { ); }; +const getRowGroup = (row: T): Partial, any>> => + get(groupKeys).reduce((acc, key) => ({ ...acc, [key]: row[key] }), {}); + +const getGroupRows = (groupVal: string) => { + if (!get(isGrouped)) { + return []; + } + + return get(mappedGroups)[groupVal].filter( + (row) => row[props.rowAttr] !== groupHeaderKey, + ); +}; + +const compareGroupsFn = (a: T, b: T) => { + const group = get(groupKeys); + if (group.length === 0) { + return false; + } + + return group.every((key) => a[key] === b[key]); +}; + +const isExpandedGroup = (value: any) => + get(collapsedRows).every((row) => !compareGroupsFn(row, value)); + +const isHiddenRow = (row: T) => { + const identifier = props.rowAttr; + return ( + get(isGrouped) && + get(collapsedRows).some((value) => row[identifier] === value[identifier]) + ); +}; + +const onToggleExpandGroup = (group: any, value?: string) => { + if (!value) { + return; + } + + const collapsed = get(collapsedRows); + + const groupExpanded = isExpandedGroup(group); + + const groupRows = getGroupRows(value); + + set( + collapsedRows, + groupExpanded + ? [...collapsed, ...groupRows] + : collapsed.filter((row) => !compareGroupsFn(row, group)), + ); + + emit('update:collapsed', get(collapsedRows)); +}; + +const onUngroup = () => { + set(collapsedRows, []); + + emit('update:collapsed', []); + emit('update:group', Array.isArray(props.group) ? [] : undefined); +}; + +const onCopyGroup = (value: GroupData) => { + emit('copy:group', value); +}; + /** * Sort to handle single sort or multiple sort columns */ @@ -569,7 +711,7 @@ const onSort = ({ key, direction, }: { - key: SortableColumnName; + key: TableRowKey; direction?: 'asc' | 'desc'; }) => { const sortBy = get(sortData); @@ -628,7 +770,7 @@ const onToggleAll = (checked: boolean) => { set( selectedData, get(selectedData)?.filter( - (identifier) => !get(filteredMap).includes(identifier), + (identifier) => !get(visibleIdentifiers).includes(identifier), ), ); } @@ -656,8 +798,22 @@ const onSelect = (checked: boolean, value: T[typeof props.rowAttr]) => { }; const search = computed(() => props.search); + +const onPaginate = () => { + emit('update:expanded', []); +}; + +const setInternalTotal = (groupedItems: T[]) => { + if (!props.paginationModifiers?.external) { + set(itemsLength, groupedItems.length); + } +}; + +const cellValue = (row: T, key: TableColumn['key']) => + row[key as TableRowKey]; + /** - * When the search query changes, we reset the current page to 1 + * on changing search query, need to reset pagination page to 1 */ watch(search, () => { const pagination = get(paginationData); @@ -666,7 +822,11 @@ watch(search, () => { } }); +watch(grouped, setInternalTotal); + onMounted(() => { + setInternalTotal(get(grouped)); + if (!get(globalItemsPerPageSettings)) { return; } @@ -676,8 +836,6 @@ onMounted(() => { limit: get(tableDefaults.itemsPerPage), }); }); - -const slots = useSlots();