Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/primevue/src/datatable/BodyRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ export default {
},
shouldRenderRowGroupHeader() {
const currentRowFieldData = resolveFieldData(this.rowData, this.groupRowsBy);
const prevRowData = this.value[this.rowIndex - 1];
const prevRowData = this.value[this.index - 1];

if (prevRowData) {
const previousRowFieldData = resolveFieldData(prevRowData, this.groupRowsBy);
Expand All @@ -569,7 +569,7 @@ export default {
return false;
} else {
let currentRowFieldData = resolveFieldData(this.rowData, this.groupRowsBy);
let nextRowData = this.value[this.rowIndex + 1];
let nextRowData = this.value[this.index + 1];

if (nextRowData) {
let nextRowFieldData = resolveFieldData(nextRowData, this.groupRowsBy);
Expand Down
58 changes: 58 additions & 0 deletions packages/primevue/src/datatable/DataTable.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1540,4 +1540,62 @@ describe('DataTable.vue', () => {
expect(rowCheckboxIcons[1].text()).toBe('CustomIcon');
expect(rowCheckboxIcons[2].text()).toBe('CustomIcon');
});

// VirtualScroller + rowGroupMode="subheader" item-size contract
it('should expose getItemSize as null when rowGroupMode is unset', () => {
expect(wrapper.vm.getItemSize).toBe(null);
});

it('should report itemSize plus a header contribution at group boundaries', async () => {
const grouped = [
{ id: 0, rep: 'Amy' },
{ id: 1, rep: 'Amy' },
{ id: 2, rep: 'John' },
{ id: 3, rep: 'John' },
{ id: 4, rep: 'Maria' }
];

await wrapper.setProps({
value: grouped,
rowGroupMode: 'subheader',
groupRowsBy: 'rep',
virtualScrollerOptions: { itemSize: 36 }
});
wrapper.vm.d_rowGroupHeaderHeight = 40;

const fn = wrapper.vm.getItemSize;

expect(typeof fn).toBe('function');
expect(fn(0)).toBe(76); // boundary (first row) + data row
expect(fn(1)).toBe(36); // continuation of Amy
expect(fn(2)).toBe(76); // boundary into John
expect(fn(3)).toBe(36); // continuation of John
expect(fn(4)).toBe(76); // boundary into Maria
});

it('should report 0 for non-anchor rows of a collapsed group', async () => {
const grouped = [
{ id: 0, rep: 'Amy' },
{ id: 1, rep: 'Amy' },
{ id: 2, rep: 'John' },
{ id: 3, rep: 'John' }
];

await wrapper.setProps({
value: grouped,
rowGroupMode: 'subheader',
groupRowsBy: 'rep',
expandableRowGroups: true,
expandedRowGroups: ['John'],
virtualScrollerOptions: { itemSize: 36 }
});
wrapper.vm.d_rowGroupHeaderHeight = 40;

const fn = wrapper.vm.getItemSize;

expect(fn(0)).toBe(40); // Amy anchor: header only (collapsed)
expect(fn(1)).toBe(0); // Amy continuation: hidden
expect(fn(2)).toBe(76); // John anchor: header + data row (expanded)
expect(fn(3)).toBe(36); // John continuation: data row only
});
});
70 changes: 68 additions & 2 deletions packages/primevue/src/datatable/DataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
:style="scrollHeight !== 'flex' ? { height: scrollHeight } : undefined"
:scrollHeight="scrollHeight !== 'flex' ? undefined : '100%'"
:disabled="virtualScrollerDisabled"
:getItemSize="getItemSize"
loaderDisabled
inline
autoSize
Expand Down Expand Up @@ -242,7 +243,7 @@
<tbody
v-if="hasSpacerStyle(slotProps.spacerStyle)"
:class="cx('virtualScrollerSpacer')"
:style="{ height: `calc(${slotProps.spacerStyle.height} - ${slotProps.rows.length * slotProps.itemSize}px)` }"
:style="{ height: `calc(${slotProps.spacerStyle.height} - ${getRenderedWindowSize(slotProps)}px)` }"
v-bind="ptm('virtualScrollerSpacer')"
></tbody>
<DTTableFooter :columnGroup="footerColumnGroup" :columns="slotProps.columns" :pt="pt" />
Expand Down Expand Up @@ -423,7 +424,8 @@ export default {
d_editingMeta: {},
d_filters: this.cloneFilters(this.filters),
d_columns: new HelperSet({ type: 'Column' }),
d_columnGroups: new HelperSet({ type: 'ColumnGroup' })
d_columnGroups: new HelperSet({ type: 'ColumnGroup' }),
d_rowGroupHeaderHeight: 0
};
},
rowTouched: false,
Expand Down Expand Up @@ -499,6 +501,8 @@ export default {
if (this.editMode === 'row' && this.dataKey && !this.d_editingRowKeys) {
this.updateEditingRowKeys(this.editingRows);
}

this.updateRowGroupHeaderHeight();
},
beforeUnmount() {
this.unbindColumnResizeEvents();
Expand All @@ -515,6 +519,8 @@ export default {
if (this.editMode === 'row' && this.dataKey && !this.d_editingRowKeys) {
this.updateEditingRowKeys(this.editingRows);
}

this.updateRowGroupHeaderHeight();
},
methods: {
columnProp(col, prop) {
Expand Down Expand Up @@ -2045,6 +2051,40 @@ export default {
},
hasSpacerStyle(style) {
return isNotEmpty(style);
},
updateRowGroupHeaderHeight() {
if (this.rowGroupMode !== 'subheader' || !this.groupRowsBy || this.virtualScrollerDisabled) {
return;
}

const header = this.$refs.table && findSingle(this.$refs.table, 'tr.p-datatable-row-group-header');

if (header) {
const height = getOuterHeight(header);

if (height && height !== this.d_rowGroupHeaderHeight) {
this.d_rowGroupHeaderHeight = height;
}
}
},
getRenderedWindowSize(slotProps) {
if (!slotProps || !slotProps.rows || !slotProps.rows.length) {
return 0;
}

const fn = this.getItemSize;

if (typeof fn !== 'function') {
return slotProps.rows.length * slotProps.itemSize;
}

let total = 0;

for (let i = 0; i < slotProps.rows.length; i++) {
total += fn(slotProps.getItemOptions(i).index);
}

return total;
}
},
computed: {
Expand Down Expand Up @@ -2111,6 +2151,32 @@ export default {

return !data || data.length === 0;
},
getItemSize() {
const baseSize = (this.virtualScrollerOptions && this.virtualScrollerOptions.itemSize) || 0;
const groupRowsBy = this.groupRowsBy;
const subheader = this.rowGroupMode === 'subheader' && !!groupRowsBy;

if (!subheader) {
return null;
}

const items = this.processedData;
const expandable = this.expandableRowGroups;
const expandedGroups = this.expandedRowGroups;
const headerHeight = this.d_rowGroupHeaderHeight;

return (index) => {
if (!items || index < 0 || index >= items.length) {
return baseSize;
}

const groupValue = resolveFieldData(items[index], groupRowsBy);
const isFirstOfGroup = index === 0 || resolveFieldData(items[index - 1], groupRowsBy) !== groupValue;
const isExpanded = !expandable || (expandedGroups && expandedGroups.indexOf(groupValue) > -1);

return (isFirstOfGroup ? headerHeight : 0) + (isExpanded ? baseSize : 0);
};
},
paginatorTop() {
return this.paginator && (this.paginatorPosition !== 'bottom' || this.paginatorPosition === 'both');
},
Expand Down
2 changes: 1 addition & 1 deletion packages/primevue/src/datatable/TableBody.vue
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export default {
},
computed: {
rowGroupHeaderStyle() {
if (this.scrollable) {
if (this.scrollable && this.isVirtualScrollerDisabled) {
return { top: this.rowGroupHeaderStyleObject.top };
}

Expand Down
4 changes: 4 additions & 0 deletions packages/primevue/src/virtualscroller/BaseVirtualScroller.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export default {
type: [Number, Array],
default: 0
},
getItemSize: {
type: Function,
default: null
},
scrollHeight: null,
scrollWidth: null,
orientation: {
Expand Down
126 changes: 126 additions & 0 deletions packages/primevue/src/virtualscroller/VirtualScroller.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { mount } from '@vue/test-utils';
import PrimeVue from 'primevue/config';
import VirtualScroller from './VirtualScroller.vue';

window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};

const buildItems = (count) => Array.from({ length: count }, (_, i) => ({ id: i }));

const mountScroller = (props = {}) => {
const wrapper = mount(VirtualScroller, {
global: { plugins: [PrimeVue] },
props: {
items: buildItems(20),
itemSize: 30,
style: { height: '120px', width: '200px' },
...props
}
});

// jsdom does not report element visibility, so viewInit() skips init().
// getContentPosition reads parseFloat('') -> NaN in jsdom; stub it to 0.
wrapper.vm.getContentPosition = () => ({ left: 0, right: 0, top: 0, bottom: 0, x: 0, y: 0 });
wrapper.vm.init();

return wrapper;
};

describe('VirtualScroller.vue', () => {
it('should exist', () => {
const wrapper = mountScroller();

expect(wrapper.find('.p-virtualscroller').exists()).toBe(true);
});

describe('uniform-size path (getItemSize not provided)', () => {
it('should not build cumulative sizes', () => {
const wrapper = mountScroller();

expect(wrapper.vm.cumulativeSizes).toBe(null);
});

it('should compute spacer height from items.length * itemSize', () => {
const wrapper = mountScroller();

expect(wrapper.vm.spacerStyle.height).toBe('600px');
});

it('should compute index from scrollTop via floor division', () => {
const wrapper = mountScroller();

expect(wrapper.vm.getIndexAtOffset(0)).toBe(0);
expect(wrapper.vm.getIndexAtOffset(90)).toBe(3);
expect(wrapper.vm.getIndexAtOffset(599)).toBe(19);
});
});

describe('variable-size path (getItemSize provided)', () => {
// header on every fifth row: sizes 80, 30, 30, 30, 30, 80, 30, ...
const stepSize = (index) => (index % 5 === 0 ? 80 : 30);

it('should build cumulative sizes from getItemSize', () => {
const wrapper = mountScroller({ getItemSize: stepSize });

const sizes = wrapper.vm.cumulativeSizes;

expect(sizes.length).toBe(21);
expect(sizes[0]).toBe(0);
expect(sizes[1]).toBe(80);
expect(sizes[5]).toBe(80 + 30 * 4);
expect(sizes[20]).toBe(80 * 4 + 30 * 16);
});

it('should compute spacer height from cumulative total', () => {
const wrapper = mountScroller({ getItemSize: stepSize });

expect(wrapper.vm.spacerStyle.height).toBe(`${80 * 4 + 30 * 16}px`);
});

it('getItemOffset should return the y-coordinate of items[index]', () => {
const wrapper = mountScroller({ getItemSize: stepSize });

expect(wrapper.vm.getItemOffset(0)).toBe(0);
expect(wrapper.vm.getItemOffset(1)).toBe(80);
expect(wrapper.vm.getItemOffset(5)).toBe(80 + 30 * 4);
});

it('getIndexAtOffset should binary-search the cumulative array', () => {
const wrapper = mountScroller({ getItemSize: stepSize });

expect(wrapper.vm.getIndexAtOffset(0)).toBe(0);
expect(wrapper.vm.getIndexAtOffset(79)).toBe(0);
expect(wrapper.vm.getIndexAtOffset(80)).toBe(1);
expect(wrapper.vm.getIndexAtOffset(199)).toBe(4);
expect(wrapper.vm.getIndexAtOffset(200)).toBe(5);
expect(wrapper.vm.getIndexAtOffset(-50)).toBe(0);
expect(wrapper.vm.getIndexAtOffset(99999)).toBe(19);
});

it('should rebuild cumulative sizes when getItemSize reference changes', async () => {
const wrapper = mountScroller({ getItemSize: stepSize });

expect(wrapper.vm.spacerStyle.height).toBe(`${80 * 4 + 30 * 16}px`);

await wrapper.setProps({ getItemSize: () => 40 });
wrapper.vm.init();

expect(wrapper.vm.spacerStyle.height).toBe('800px');
});

it('should fall back to uniform math when getItemSize is removed', async () => {
const wrapper = mountScroller({ getItemSize: stepSize });

expect(wrapper.vm.cumulativeSizes).not.toBe(null);

await wrapper.setProps({ getItemSize: null });
wrapper.vm.init();

expect(wrapper.vm.cumulativeSizes).toBe(null);
expect(wrapper.vm.spacerStyle.height).toBe('600px');
});
});
});
Loading