diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 634c6a986e06..d2470ec93bfb 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -201,6 +201,8 @@ export default defineConfig([ // Allow more than one class definitions per file (test components) 'max-classes-per-file': 'off', + // Test harnesses expose `expect*` helpers that wrap several assertions + 'vitest/expect-expect': ['error', { assertFunctionNames: ['expect', '*.expect*'] }], }, }, { diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-order.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-order.spec.ts new file mode 100644 index 000000000000..45e64fb5cd99 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-order.spec.ts @@ -0,0 +1,55 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { buildTable, TableHarness } from '../../../testing/table-harness'; + +describe('Hierarchy ordering across query windows', () => { + let harness:TableHarness; + const parent = { id: '1' }; + const child = { id: '2', ancestors: [parent] }; + const other = { id: '3' }; + + afterEach(() => harness?.destroy()); + + it('inserts a parent absent from the result window before its first child', async () => { + harness = buildTable({ workPackages: [child, other], showHierarchies: true }); + await harness.render(); + expect(harness.rowIds()).toEqual(['1', '2', '3']); + }); + + it('currently defers the child when its parent is in the same window', async () => { + harness = buildTable({ workPackages: [child, other, parent], showHierarchies: true }); + await harness.render(); + expect(harness.rowIds()).toEqual(['3', '1', '2']); + }); + + it('keeps a child under an earlier parent', async () => { + harness = buildTable({ workPackages: [parent, other, child], showHierarchies: true }); + await harness.render(); + expect(harness.rowIds()).toEqual(['1', '2', '3']); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-lifetime.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-lifetime.spec.ts index e70bc6b92941..a0de2371befb 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-lifetime.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-lifetime.spec.ts @@ -133,7 +133,7 @@ describe('accepted table drag persistence lifetime', () => { await waitFor(() => expect(calls).toEqual(expected)); } - async function mount(kind:'default'|'group'|'hierarchy' = 'group', saved = true) { + async function mount(kind:'default'|'group'|'hierarchy' = 'group', saved = true, positions = false) { const grouped = kind === 'group'; original = query(saved, grouped, kind === 'hierarchy'); const providers:Provider[] = [ @@ -178,7 +178,7 @@ describe('accepted table drag persistence lifetime', () => { views: { post: view }, } }, ]; - dom = buildDom(); + dom = buildDom({ productionMarkup: positions }); table = buildTable({ query: original, builtinDragActions: true, @@ -194,6 +194,11 @@ describe('accepted table drag persistence lifetime', () => { ], groups: grouped ? [{ value: 'New', href: status1.href, count: 1 }, { value: 'Progress', href: status2.href, count: 2 }] : undefined, showHierarchies: kind === 'hierarchy', + ...(positions ? { + productionMarkup: true, + pagination: { offset: 1, pageSize: 50, total: 3 }, + configuration: { keyboardNavigationEnabled: true }, + } : {}), }); await table.render(); table.injector.get(WorkPackageViewPaginationService).initialize(original, { elements: [], offset: 1, pageSize: 50, total: 3, count: 3 } as unknown as WorkPackageCollectionResource); @@ -455,6 +460,46 @@ describe('accepted table drag persistence lifetime', () => { checkSuccessor(); }); + describe('with row positions', () => { + const positions = ['1', '2', '3', '4', '5', '6']; + + it('stays withdrawn through a switch to manual sorting until a matching collection arrives', async () => { + await mount('default', true, true); + table.expectRowPositions(positions, '6'); + const rebuilt = table.nextRender(); + const dropping = table.drop('1', '2', 'bottom'); + await expectCalls(['order']); + expect(table.rowIds()).toEqual(['2', '1', '3']); + table.expectNoRowPositions(); + orderSaved.resolve('now'); + await expectCalls(['order', 'query']); + table.expectNoRowPositions(); + querySaved.next(original); + expect(await dropping).toBe(true); + expect(original.sortBy).toEqual([manualSort]); + await rebuilt; + expect(table.rowIds()).toEqual(['2', '1', '3']); + table.expectNoRowPositions(); + + await table.render(['2', '1', '3'].map((id) => ({ id, attributes: { status: id === '1' ? status1 : status2 } }))); + table.expectRowPositions(positions, '6'); + }); + + it('stays withdrawn when the saved order remains visible after query saving fails', async () => { + await mount('default', true, true); + const rebuilt = table.nextRender(); + const dropping = table.drop('1', '2', 'bottom'); + await expectCalls(['order']); + orderSaved.resolve('now'); + await expectCalls(['order', 'query']); + querySaved.error(new Error('query failed')); + expect(await dropping).toBe(false); + await rebuilt; + expect(table.rowIds()).toEqual(['2', '1', '3']); + table.expectNoRowPositions(); + }); + }); + it('lets the adapter report a hierarchy failure once with the captured resource', async () => { await mount('hierarchy'); const dropping = table.drop('1', '3', 'bottom'); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts index f162e6b78227..69748a9c9a93 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts @@ -205,6 +205,7 @@ export class DragAndDropTransformer { recoverIfCurrent = () => { if (!isCurrent() || table.lastRenderPass !== renderPass || source.parentNode !== parentNode) return; parentNode?.insertBefore(source, nextSibling?.parentNode === parentNode ? nextSibling : null); + table.rowPositions?.paint(); }; if (!stillEligible()) { complete(false); @@ -234,6 +235,7 @@ export class DragAndDropTransformer { // Only preparation observes the staged neighbors. Persistence captures no row. if (target) table.tbody.insertBefore(source, edge === 'top' ? target : target.nextSibling); else table.tbody.appendChild(source); + table.rowPositions?.paint(); const preparedAction = await actionService.prepareDrop(workPackage, source); if (!stillEligible()) { recoverIfCurrent(); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/navigable-rows.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/navigable-rows.ts index c23d8c3b5e95..ce4d15744892 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/navigable-rows.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/navigable-rows.ts @@ -31,20 +31,12 @@ import type { WorkPackageTable } from '../../../wp-fast-table'; import { NavigableRow, RowKind } from './focus-position'; import { navigableCells } from './roving-tabindex'; import { expandedOf, rowKindOf } from '../../../helpers/row-expansion'; +import { headerRowOf, totalsRowOf } from '../../../helpers/structural-rows'; export const headerRowKey = 'header'; export const totalsRowKey = 'totals'; -/** The `thead` row of the table, when rendered. */ -export function headerRowOf(table:WorkPackageTable):HTMLTableRowElement|null { - return table.tbody.closest('table')?.querySelector('thead tr') ?? null; -} - -/** The totals row below the results, when rendered. */ -export function totalsRowOf(table:WorkPackageTable):HTMLTableRowElement|null { - const rows = table.tbody.closest('table')?.querySelectorAll('tr.wp-table--sums-row') ?? []; - return Array.from(rows).find((row) => !table.tbody.contains(row)) ?? null; -} +export { headerRowOf, totalsRowOf }; function occurrenceRow( table:WorkPackageTable, diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/structural-rows.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/structural-rows.spec.ts new file mode 100644 index 000000000000..31c5b838b21d --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/structural-rows.spec.ts @@ -0,0 +1,82 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { buildTable, TableHarness } from '../testing/table-harness'; +import { groupOccurrenceKey } from '../rendered-occurrence-ledger'; +import { + headerRowOf, isEmptyResultsRow, isExposedRow, isInlineCreateRow, isTotalsRow, totalsRowOf, +} from './structural-rows'; + +describe('structural rows', () => { + let harness:TableHarness; + + afterEach(() => harness?.destroy()); + + it('classifies the totals row below the results but not a group sums row', async () => { + const status = { href: '/api/v3/statuses/1' }; + harness = buildTable({ + workPackages: [{ id: '1', attributes: { status } }], + productionMarkup: true, + groups: [{ value: 'G', href: status.href, count: 1, sums: { storyPoints: 1 } }], + }); + await harness.render(); + const groupSums = harness.table.ledger.byKey(groupOccurrenceKey(0, 'sums'))!.element!; + expect(isTotalsRow(harness.table, harness.totalsRow())).toBe(true); + expect(isTotalsRow(harness.table, groupSums)).toBe(false); + expect(totalsRowOf(harness.table)).toBe(harness.totalsRow()); + expect(headerRowOf(harness.table)).toBe(harness.headerRow()); + }); + + it('classifies the create and empty-results rows by their sections', async () => { + harness = buildTable({ workPackages: [], productionMarkup: true }); + await harness.render(); + const createRow = harness.table.tbody.closest('table')!.querySelector('tbody[wpInlineCreate] tr')!; + const empty = document.createElement('tbody'); + empty.className = 'work-package--empty-tbody'; + const emptyRow = empty.insertRow(); + harness.tbody.before(empty); + expect(isInlineCreateRow(createRow)).toBe(true); + expect(isInlineCreateRow(harness.totalsRow())).toBe(false); + expect(isEmptyResultsRow(emptyRow)).toBe(true); + expect(isEmptyResultsRow(createRow)).toBe(false); + }); + + it('treats rows hidden themselves or by their section as not exposed', async () => { + harness = buildTable({ workPackages: [{ id: '1' }], productionMarkup: true }); + await harness.render(); + const row = harness.row('1'); + expect(isExposedRow(row)).toBe(true); + row.setAttribute('aria-hidden', 'true'); + expect(isExposedRow(row)).toBe(false); + row.removeAttribute('aria-hidden'); + row.hidden = true; + expect(isExposedRow(row)).toBe(false); + row.hidden = false; + harness.tbody.setAttribute('aria-hidden', 'true'); + expect(isExposedRow(row)).toBe(false); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/structural-rows.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/structural-rows.ts new file mode 100644 index 000000000000..a17791ac3249 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/structural-rows.ts @@ -0,0 +1,61 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import type { WorkPackageTable } from '../wp-fast-table'; + +const totalsRowSelector = 'tr.wp-table--sums-row'; + +/** The `thead` row of the table, when rendered. */ +export function headerRowOf(table:WorkPackageTable):HTMLTableRowElement|null { + return table.tbody.closest('table')?.querySelector('thead tr') ?? null; +} + +/** The totals row below the results; group sums rows inside the results body do not qualify. */ +export function isTotalsRow(table:WorkPackageTable, row:HTMLTableRowElement):boolean { + return row.matches(totalsRowSelector) && !table.tbody.contains(row); +} + +/** The totals row below the results, when rendered. */ +export function totalsRowOf(table:WorkPackageTable):HTMLTableRowElement|null { + const rows = table.tbody.closest('table')?.querySelectorAll(totalsRowSelector) ?? []; + return Array.from(rows).find((row) => isTotalsRow(table, row)) ?? null; +} + +export function isInlineCreateRow(row:HTMLTableRowElement):boolean { + return row.parentElement?.hasAttribute('wpInlineCreate') === true; +} + +export function isEmptyResultsRow(row:HTMLTableRowElement):boolean { + return row.parentElement?.classList.contains('work-package--empty-tbody') === true; +} + +/** A row assistive technology can reach: neither hidden itself nor inside a hidden section. */ +export function isExposedRow(row:HTMLTableRowElement):boolean { + return !row.hidden + && row.getAttribute('aria-hidden') !== 'true' + && row.parentElement?.getAttribute('aria-hidden') !== 'true'; +} diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.spec.ts new file mode 100644 index 000000000000..12e0e1d7582b --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.spec.ts @@ -0,0 +1,134 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { bodyRowPositions, draftAgrees, PositionWindow } from './body-row-positions'; + +const groups = [ + { headerKey: 'G', sumsKey: 'G-sum', count: 4, hasSums: true }, + { headerKey: 'H', sumsKey: 'H-sum', count: 1, hasSums: true }, +]; + +function window(overrides:Partial = {}):PositionWindow { + return { pageNumber: 1, pageSize: 20, total: 100, primaryKeys: [], groups: [], ...overrides }; +} + +describe('bodyRowPositions', () => { + it('retains global ordinals on the second flat page', () => { + const primaryKeys = Array.from({ length: 20 }, (_, index) => String(index)); + const result = bodyRowPositions(window({ pageNumber: 2, primaryKeys })); + expect(result?.count).toBe(100); + expect(result?.ordinals.get('0')).toBe(21); + expect(result?.ordinals.get('19')).toBe(40); + }); + + it('includes every group boundary while preserving unloaded member gaps', () => { + const result = bodyRowPositions(window({ pageNumber: 2, pageSize: 2, total: 5, primaryKeys: ['C', 'D'], groups })); + expect(result?.count).toBe(9); + expect(Array.from(result?.ordinals ?? [])).toEqual([ + ['G', 1], ['G-sum', 6], ['H', 7], ['H-sum', 9], ['C', 4], ['D', 5], + ]); + }); + + it('maps a window crossing a group boundary', () => { + const result = bodyRowPositions(window({ pageNumber: 2, pageSize: 3, total: 5, primaryKeys: ['D', 'E'], groups })); + expect(result?.ordinals.get('D')).toBe(5); + expect(result?.ordinals.get('E')).toBe(8); + }); + + it('omits boundaries for zero count groups', () => { + const result = bodyRowPositions(window({ total: 1, primaryKeys: ['A'], groups: [ + { headerKey: 'empty', sumsKey: 'empty-sum', count: 0, hasSums: true }, + { headerKey: 'G', sumsKey: 'unused-sum', count: 1, hasSums: false }, + ] })); + expect(result?.count).toBe(2); + expect(Array.from(result?.ordinals ?? [])).toEqual([['G', 1], ['A', 2]]); + }); + + it('accepts empty exact collections and out-of-range empty pages', () => { + expect(bodyRowPositions(window({ total: 0 }))?.count).toBe(0); + expect(bodyRowPositions(window({ pageNumber: 100, total: 0 }))?.ordinals.size).toBe(0); + expect(bodyRowPositions(window({ pageNumber: 100, total: 5, groups }))?.count).toBe(9); + }); + + const invalid:Partial[] = [ + { pageNumber: 0 }, { pageNumber: -1 }, { pageNumber: 1.5 }, { pageNumber: NaN }, + { pageSize: 0 }, { pageSize: Infinity }, { pageSize: undefined }, + { total: -1 }, { total: 1.5 }, { total: undefined }, + { pageSize: 1, primaryKeys: ['A', 'B'] }, + { primaryKeys: ['A', 'A'] }, + { total: 0, primaryKeys: ['A'] }, + { pageNumber: 6, pageSize: 20, primaryKeys: ['A'] }, + { pageNumber: Number.MAX_SAFE_INTEGER, pageSize: 20 }, + { total: -1, groups }, { total: 6, groups }, + { total: 0, groups: [{ ...groups[0], count: -1 }] }, + { total: 0, groups: [{ ...groups[0], count: 0.5 }] }, + { total: 5, groups, primaryKeys: ['G'] }, + { total: 5, groups: [groups[0], { ...groups[1], headerKey: 'G' }] }, + { total: Number.MAX_SAFE_INTEGER, groups: [{ ...groups[0], count: Number.MAX_SAFE_INTEGER }] }, + { total: -1, pageNumber: Number.MAX_SAFE_INTEGER, pageSize: 1, primaryKeys: ['A', 'B'] }, + ]; + + it.each(invalid)('rejects contradictory or unsafe metadata %o', (overrides) => { + expect(bodyRowPositions(window(overrides))).toBeNull(); + }); +}); + +describe('draftAgrees', () => { + const member = (key:string) => ({ key, member: true }); + const structural = (key:string) => ({ key, member: false }); + const flat = () => bodyRowPositions(window({ primaryKeys: ['A', 'B'] }))!; + const grouped = () => bodyRowPositions(window({ pageNumber: 2, pageSize: 2, total: 5, primaryKeys: ['C', 'D'], groups }))!; + + it('accepts increasing flat ordinals', () => { + expect(draftAgrees(flat(), [member('A'), member('B')], [])).toBe(true); + }); + + it('rejects flat rows out of rank order', () => { + expect(draftAgrees(flat(), [member('B'), member('A')], [])).toBe(false); + }); + + it('accepts headers, members and sums in group order', () => { + const rows = [structural('G'), member('C'), member('D'), structural('G-sum'), structural('H'), structural('H-sum')]; + expect(draftAgrees(grouped(), rows, groups)).toBe(true); + }); + + it('rejects a member rendered under another group header', () => { + expect(draftAgrees(grouped(), [structural('H'), member('C')], groups)).toBe(false); + }); + + it('rejects a sums row before its members', () => { + expect(draftAgrees(grouped(), [structural('G'), structural('G-sum'), member('C')], groups)).toBe(false); + }); + + it('rejects a structural row that is not the active sums row', () => { + expect(draftAgrees(grouped(), [structural('G'), structural('H-sum')], groups)).toBe(false); + }); + + it('rejects unknown keys', () => { + expect(draftAgrees(grouped(), [structural('G'), member('X')], groups)).toBe(false); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.ts new file mode 100644 index 000000000000..d692d7a735fb --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.ts @@ -0,0 +1,170 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +export interface BodyRowPositions { + readonly count:number; + readonly ordinals:ReadonlyMap; +} + +export interface PositionGroup { + readonly headerKey:string; + readonly sumsKey:string; + readonly count:number; + readonly hasSums:boolean; +} + +export interface PositionWindow { + readonly pageNumber:number; + readonly pageSize:number; + readonly total:number; + readonly primaryKeys:readonly string[]; + readonly groups:readonly PositionGroup[]; +} + +export function bodyRowPositions(window:PositionWindow):BodyRowPositions|null { + const { pageNumber, pageSize, total, primaryKeys, groups } = window; + if (!Number.isSafeInteger(pageNumber) || pageNumber < 1 + || !Number.isSafeInteger(pageSize) || pageSize < 1 + || !Number.isSafeInteger(total) || total < 0 + || primaryKeys.length > pageSize) { + return null; + } + + const firstQueryRank = (pageNumber - 1) * pageSize; + const lastQueryRank = firstQueryRank + primaryKeys.length; + if (!Number.isSafeInteger(firstQueryRank) || !Number.isSafeInteger(lastQueryRank) + || (primaryKeys.length > 0 && lastQueryRank > total)) { + return null; + } + + const ordinals = new Map(); + const add = (key:string, ordinal:number):boolean => { + if (ordinals.has(key) || !Number.isSafeInteger(ordinal) || ordinal < 1) { + return false; + } + ordinals.set(key, ordinal); + return true; + }; + + if (groups.length === 0) { + for (const [index, key] of primaryKeys.entries()) { + if (!add(key, firstQueryRank + index + 1)) { + return null; + } + } + return { count: total, ordinals }; + } + + let queryPrefix = 0; + let bodyPrefix = 0; + const boundaries:{ queryPrefix:number; queryEnd:number; headerOrdinal:number }[] = []; + for (const group of groups) { + if (!Number.isSafeInteger(group.count) || group.count < 0) { + return null; + } + if (group.count === 0) { + continue; + } + + const headerOrdinal = bodyPrefix + 1; + const queryEnd = queryPrefix + group.count; + const bodyEnd = bodyPrefix + group.count + 1 + Number(group.hasSums); + if (!Number.isSafeInteger(queryEnd) || !Number.isSafeInteger(bodyEnd) + || !add(group.headerKey, headerOrdinal) + || (group.hasSums && !add(group.sumsKey, bodyEnd))) { + return null; + } + boundaries.push({ queryPrefix, queryEnd, headerOrdinal }); + queryPrefix = queryEnd; + bodyPrefix = bodyEnd; + } + + if (queryPrefix !== total) { + return null; + } + + let groupIndex = 0; + for (const [index, key] of primaryKeys.entries()) { + const rank = firstQueryRank + index; + while (groupIndex < boundaries.length && rank >= boundaries[groupIndex].queryEnd) { + groupIndex += 1; + } + const boundary = boundaries[groupIndex]; + if (!boundary || !add(key, boundary.headerOrdinal + 1 + (rank - boundary.queryPrefix))) { + return null; + } + } + + return { count: bodyPrefix, ordinals }; +} + +export interface DraftRow { + readonly key:string; + readonly member:boolean; +} + +interface MemberRange { + readonly first:number; + readonly last:number; + readonly sumsKey:string; +} + +export function draftAgrees(body:BodyRowPositions, rows:readonly DraftRow[], groups:readonly PositionGroup[]):boolean { + const ranges = new Map(); + for (const group of groups) { + if (group.count === 0) { + continue; + } + const header = body.ordinals.get(group.headerKey); + if (header === undefined) { + return false; + } + ranges.set(group.headerKey, { first: header + 1, last: header + group.count, sumsKey: group.sumsKey }); + } + + let active:MemberRange|undefined; + let previous = 0; + for (const row of rows) { + const ordinal = body.ordinals.get(row.key); + if (ordinal === undefined || ordinal <= previous) { + return false; + } + const range = ranges.get(row.key); + if (range) { + active = range; + } else if (groups.length > 0) { + if (!active) { + return false; + } + if (row.member ? ordinal < active.first || ordinal > active.last : row.key !== active.sumsKey) { + return false; + } + } + previous = ordinal; + } + return true; +} diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.spec.ts new file mode 100644 index 000000000000..4c5d5caed86e --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.spec.ts @@ -0,0 +1,481 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { buildTable, TableHarness, TableHarnessOptions } from '../testing/table-harness'; +import { WorkPackageViewCollapsedGroupsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service'; +import { waitFor } from '@testing-library/dom'; +import { nextTask } from 'core-common/testing/timing'; +import { States } from 'core-app/core/states/states.service'; +import { groupOccurrenceKey } from '../rendered-occurrence-ledger'; +import { buildWorkPackage } from '../testing/work-package-fixture'; + +function deferred() { + let resolve!:(value:T) => void; + let reject!:(error:unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +describe('TableRowPositions', () => { + let harness:TableHarness; + const mount = (options:Partial = {}) => { + harness = buildTable({ + workPackages: [{ id: '21' }, { id: '22' }], + productionMarkup: true, + pagination: { offset: 2, pageSize: 20, total: 100 }, + ...options, + configuration: { keyboardNavigationEnabled: true, ...options.configuration }, + }); + return harness.render(); + }; + const table = () => harness.tbody.closest('table')!; + const index = (row:Element) => row.getAttribute('aria-rowindex'); + const createRow = () => table().querySelector('tbody[wpInlineCreate] tr')!; + const expectIndices = (rows:Element[], indices:string[]) => { + expect(rows).toHaveLength(indices.length); + rows.forEach((row, position) => expect(row).toHaveAttribute('aria-rowindex', indices[position])); + }; + afterEach(() => harness?.destroy()); + + it('uses global positions and includes trailing totals and Create', async () => { + await mount(); + harness.expectRowPositions(['1', '22', '23', '102', '103'], '103'); + }); + + it('reprojects local structure without changing the body coordinates', async () => { + await mount(); + harness.totalsRow().setAttribute('aria-hidden', 'true'); + harness.table.rowPositions!.paint(); + expect(harness.totalsRow()).not.toHaveAttribute('aria-rowindex'); + expect(harness.row('21')).toHaveAttribute('aria-rowindex', '22'); + expect(createRow()).toHaveAttribute('aria-rowindex', '102'); + expect(table()).toHaveAttribute('aria-rowcount', '102'); + }); + + it('leaves nested tables alone', async () => { + await mount(); + const nested = document.createElement('table'); + nested.innerHTML = 'nested'; + harness.row('21').cells[0].append(nested); + harness.table.rowPositions!.paint(); + expect(nested.rows[0]).toHaveAttribute('aria-rowindex', '7'); + expect(table()).toHaveAttribute('aria-rowcount', '103'); + }); + + it('clears the whole family for an exposed unclassified row', async () => { + await mount(); + const extra = table().createTBody().insertRow(); + extra.insertCell().textContent = 'Unclassified'; + harness.table.rowPositions!.paint(); + harness.expectNoRowPositions(); + }); + + it('keeps group continuation and collapsed gaps global', async () => { + const status = { href: '/api/v3/statuses/1' }; + await mount({ + workPackages: [{ id: '3', attributes: { status } }, { id: '4', attributes: { status } }], + pagination: { offset: 2, pageSize: 2, total: 5 }, + groups: [ + { value: 'G', href: status.href, count: 4, sums: { storyPoints: 5 } }, + { value: 'H', href: '/api/v3/statuses/2', count: 1, sums: { storyPoints: 2 } }, + ], + }); + const header = () => harness.groupHeader(0); + const sum = () => harness.table.ledger.byKey(groupOccurrenceKey(0, 'sums'))!.element!; + expectIndices([header(), harness.row('3'), harness.row('4'), sum()], ['2', '5', '6', '7']); + expect(harness.totalsRow()).toHaveAttribute('aria-rowindex', '11'); + expect(table()).toHaveAttribute('aria-rowcount', '12'); + harness.injector.get(WorkPackageViewCollapsedGroupsService).toggleGroupCollapseState(header().dataset.groupIdentifier!); + expect(header()).toHaveAttribute('aria-rowindex', '2'); + expect(harness.totalsRow()).toHaveAttribute('aria-rowindex', '11'); + expect(table()).toHaveAttribute('aria-rowcount', '12'); + }); + + it('rejects a group header that disagrees with the query rank', async () => { + await mount({ + workPackages: [{ id: '5', attributes: { status: { href: '/api/v3/statuses/1' } } }], + pagination: { offset: 3, pageSize: 2, total: 5 }, + groups: [ + { value: 'G', href: '/api/v3/statuses/1', count: 4 }, + { value: 'H', href: '/api/v3/statuses/2', count: 1 }, + ], + }); + harness.expectNoRowPositions(); + }); + + it('does not infer a flat count for hierarchy mode', async () => { + await mount({ showHierarchies: true }); + expect(table()).not.toHaveAttribute('aria-rowcount'); + expect(harness.row('21')).not.toHaveAttribute('aria-rowindex'); + }); + + it('does not activate capability-off consumers', async () => { + await mount({ configuration: { keyboardNavigationEnabled: false } }); + expect(harness.table.rowPositions).toBeNull(); + expect(table()).not.toHaveAttribute('aria-rowcount'); + }); + + it('counts the genuinely empty message and excludes the drag placeholder', async () => { + await mount({ workPackages: [], pagination: { offset: 1, pageSize: 20, total: 0 }, configuration: { dragAndDropEnabled: true } }); + const empty = document.createElement('tbody'); + empty.className = 'work-package--empty-tbody'; + const row = empty.insertRow(); + row.insertCell().textContent = 'No results'; + harness.tbody.before(empty); + harness.table.rowPositions!.paint(); + expect(row).toHaveAttribute('aria-rowindex', '2'); + expect(harness.totalsRow()).toHaveAttribute('aria-rowindex', '3'); + expect(table()).toHaveAttribute('aria-rowcount', '4'); + expect(harness.tbody.rows[0]).not.toHaveAttribute('aria-rowindex'); + }); + + it('does not label an out-of-range page as an empty query', async () => { + await mount({ workPackages: [], pagination: { offset: 10, pageSize: 20, total: 100 } }); + const empty = document.createElement('tbody'); + empty.className = 'work-package--empty-tbody'; + empty.insertRow().insertCell().textContent = 'No results'; + harness.tbody.before(empty); + harness.table.rowPositions!.paint(); + harness.expectNoRowPositions(); + }); + + it('clears stale values for duplicate, backwards or missing descriptors', async () => { + await mount(); + const variants:ReadonlyMap[] = [ + new Map([['wp:21', 22], ['wp:22', 21]]), + new Map([['wp:21', 21], ['wp:22', 21]]), + new Map([['wp:21', 21]]), + ]; + for (const ordinals of variants) { + harness.table.rowPositions!.commit({ source: harness.querySpace.results.value!, body: { count: 100, ordinals } }); + harness.table.rowPositions!.paint(); + harness.expectNoRowPositions(); + } + }); + + it('clears stale coordinates before a newer collection commits', async () => { + await mount(); + const frames:(() => void)[] = []; + vi.spyOn(harness.table.uiWork, 'frame').mockImplementation((callback) => { frames.push(callback); }); + const rendered = harness.render([{ id: '31' }, { id: '32' }]); + harness.table.rowPositions!.paint(); + expect(table()).not.toHaveAttribute('aria-rowcount'); + expect(harness.row('21')).not.toHaveAttribute('aria-rowindex'); + frames.splice(0).forEach((callback) => callback()); + await rendered; + expect(harness.row('31')).toHaveAttribute('aria-rowindex', '22'); + expect(table()).toHaveAttribute('aria-rowcount', '103'); + }); + + it('paints replacement work-package rows before refresh notification', async () => { + await mount(); + let observed:string|null = null; + harness.table.rowsRefreshed$.subscribe(() => { observed = index(harness.row('21')); }); + const resource = harness.table.originalRowIndex['21'].object; + harness.table.refreshRows(resource); + expect(observed).toBe('22'); + }); + + it('stops painting after teardown', async () => { + await mount(); + await harness.destroy(); + table().removeAttribute('aria-rowcount'); + harness.table.rowPositions!.paint(); + expect(table()).not.toHaveAttribute('aria-rowcount'); + }); + + it('does not treat an offscreen expanded owner as zero additional rows', async () => { + harness = buildTable({ + workPackages: [{ id: '21' }], productionMarkup: true, + pagination: { offset: 2, pageSize: 20, total: 100 }, + columns: ['id', 'subject', { id: 'relationsOfTypeFollows', relationType: 'follows' }], + configuration: { keyboardNavigationEnabled: true }, + }); + harness.expand('999', 'relationsOfTypeFollows'); + await harness.render(); + expect(table()).not.toHaveAttribute('aria-rowcount'); + }); + + describe('while a dragged row is staged', () => { + let prepared:ReturnType>; + let persisted:ReturnType>; + const dragAction = { + prepareDrop: () => prepared.promise.then(() => ({ persist: () => persisted.promise })), + }; + const cleared = () => harness.expectNoRowPositions(); + const mountFlat = () => mount({ workPackages: [{ id: '21' }, { id: '22' }, { id: '23' }], dragAction }); + const staged = (ids:string[]) => waitFor(() => expect(harness.rowIds()).toEqual(ids)); + + beforeEach(() => { + prepared = deferred(); + persisted = deferred(); + }); + + it('withdraws positions until preparation settles and restores them after it fails', async () => { + await mountFlat(); + const dropping = harness.drop('21', '22', 'bottom'); + await staged(['22', '21', '23']); + cleared(); + + prepared.reject(new Error('prepare failed')); + expect(await dropping).toBe(false); + expect(harness.rowIds()).toEqual(['21', '22', '23']); + expectIndices(harness.rows(), ['22', '23', '24']); + expect(table()).toHaveAttribute('aria-rowcount', '103'); + }); + + it('stays withdrawn during persistence and restores positions after it fails', async () => { + await mountFlat(); + const dropping = harness.drop('21', '22', 'bottom'); + await staged(['22', '21', '23']); + prepared.resolve(); + await nextTask(); + cleared(); + + persisted.reject(new Error('persist failed')); + expect(await dropping).toBe(false); + expect(harness.rowIds()).toEqual(['21', '22', '23']); + expectIndices(harness.rows(), ['22', '23', '24']); + }); + + it('waits for a matching collection after a successful drop', async () => { + await mountFlat(); + const rebuilt = harness.nextRender(); + const dropping = harness.drop('21', '22', 'bottom'); + await staged(['22', '21', '23']); + prepared.resolve(); + persisted.resolve(); + expect(await dropping).toBe(true); + await rebuilt; + expect(harness.rowIds()).toEqual(['22', '21', '23']); + cleared(); + + await harness.render([{ id: '22' }, { id: '21' }, { id: '23' }]); + expectIndices(harness.rows(), ['22', '23', '24']); + expect(table()).toHaveAttribute('aria-rowcount', '103'); + }); + + it('keeps the moved row, its selection and tab stop while withdrawn', async () => { + await mountFlat(); + const moved = harness.row('21'); + harness.click('21'); + expect(moved).toHaveAttribute('tabindex', '0'); + const dropping = harness.drop('21', '22', 'bottom'); + await staged(['22', '21', '23']); + expect(harness.row('21')).toBe(moved); + expect(moved).toHaveClass('-checked'); + expect(moved).toHaveAttribute('tabindex', '0'); + prepared.reject(new Error('prepare failed')); + await dropping; + expect(harness.row('21')).toBe(moved); + expect(moved).toHaveClass('-checked'); + expect(moved).toHaveAttribute('tabindex', '0'); + }); + + it('keeps grouped gaps when a staged move is put back', async () => { + const status1 = { href: '/api/v3/statuses/1' }; + const status2 = { href: '/api/v3/statuses/2' }; + await mount({ + workPackages: [ + { id: '3', attributes: { status: status1 } }, + { id: '4', attributes: { status: status1 } }, + { id: '5', attributes: { status: status2 } }, + ], + pagination: { offset: 1, pageSize: 3, total: 5 }, + groups: [ + { value: 'G', href: status1.href, count: 2 }, + { value: 'H', href: status2.href, count: 3 }, + ], + dragAction, + }); + expectIndices(harness.rows(), ['3', '4', '6']); + const dropping = harness.drop('3', '5', 'bottom'); + await staged(['4', '5', '3']); + cleared(); + prepared.reject(new Error('prepare failed')); + await dropping; + expectIndices(harness.rows(), ['3', '4', '6']); + expect(table()).toHaveAttribute('aria-rowcount', '10'); + }); + + it('does not paint a torn-down table when a staged move settles', async () => { + await mountFlat(); + const dropping = harness.drop('21', '22', 'bottom'); + await staged(['22', '21', '23']); + await harness.destroy(); + prepared.reject(new Error('prepare failed')); + expect(await dropping).toBe(false); + expect(harness.rowIds()).toEqual(['22', '21', '23']); + cleared(); + }); + }); + + describe('with the children column', () => { + const children = { id: 'children', children: true } as const; + + it('does not treat an offscreen expanded owner as zero additional rows', async () => { + await mount({ workPackages: [{ id: '21' }], columns: ['id', 'subject', children] }); + harness.expand('999', 'children'); + await harness.render(); + expect(table()).not.toHaveAttribute('aria-rowcount'); + expect(harness.row('21')).not.toHaveAttribute('aria-rowindex'); + }); + + it('stays absent while expanded children load and after they appear', async () => { + let resolveLoad!:() => void; + const loaded = new Promise((resolve) => { resolveLoad = resolve; }); + harness = buildTable({ + workPackages: [{ id: '21', children: [{ id: '23' }] }, { id: '22' }], + productionMarkup: true, + pagination: { offset: 2, pageSize: 20, total: 100 }, + columns: ['id', 'subject', children], + loadChildren: false, + configuration: { keyboardNavigationEnabled: true }, + requireAll: async (ids) => { + await loaded; + return ids.map((id) => { + const child = buildWorkPackage({ id }); + harness.injector.get(States).workPackages.get(id).putValue(child); + return child; + }); + }, + }); + harness.expand('21', 'children'); + await harness.render(); + expect(table()).not.toHaveAttribute('aria-rowcount'); + expect(harness.row('21')).not.toHaveAttribute('aria-rowindex'); + + const redrawn = harness.nextRender(); + resolveLoad(); + await redrawn; + expect(harness.relationRow('21', '23', 'children')).toBeInTheDocument(); + harness.expectNoRowPositions(); + }); + + it('keeps global positions when nothing is expanded', async () => { + await mount({ columns: ['id', 'subject', children] }); + expect(harness.row('21')).toHaveAttribute('aria-rowindex', '22'); + expect(table()).toHaveAttribute('aria-rowcount', '103'); + }); + }); + + + it('publishes indices from the accepted render instead of a superseded frame', async () => { + await mount(); + const frames:(() => void)[] = []; + vi.spyOn(harness.table.uiWork, 'frame').mockImplementation((callback) => { frames.push(callback); }); + const first = harness.render([{ id: '31' }]); + const second = harness.render([{ id: '41' }, { id: '42' }]); + const publications:(string|null)[][] = []; + const subscription = harness.querySpace.tableRendered.values$().subscribe(() => { + publications.push(harness.rows().map(index)); + }); + frames.splice(0).forEach((callback) => callback()); + await Promise.all([first, second]); + subscription.unsubscribe(); + expect(harness.rowIds()).toEqual(['41', '42']); + expect(publications.at(-1)).toEqual(['22', '23']); + }); + + it('clears indices when group counts do not match the query total', async () => { + const status = { href: '/api/v3/statuses/1' }; + await mount({ + workPackages: [{ id: '1', attributes: { status } }], + pagination: { offset: 1, pageSize: 20, total: 10 }, + groups: [{ value: 'G', href: status.href, count: 2 }], + }); + expect(table()).not.toHaveAttribute('aria-rowcount'); + expect(harness.row('1')).not.toHaveAttribute('aria-rowindex'); + }); + + it.each([ + '/api/v3/work_packages?offset=1', + '/api/v3/projects/12/work_packages', + '/api/v3/workspaces/12/work_packages?pageSize=20', + ])('trusts grouped aggregates of the primary collection %s', async (collectionHref) => { + const status = { href: '/api/v3/statuses/1' }; + await mount({ + workPackages: [{ id: '1', attributes: { status } }], + pagination: { offset: 1, pageSize: 20, total: 1 }, + groups: [{ value: 'G', href: status.href, count: 1 }], + collectionHref, + }); + expect(table()).toHaveAttribute('aria-rowcount', '5'); + }); + + it('does not use grouped aggregates of a scoped collection', async () => { + const status = { href: '/api/v3/statuses/1' }; + await mount({ + workPackages: [{ id: '1', attributes: { status } }], + pagination: { offset: 1, pageSize: 20, total: 1 }, + groups: [{ value: 'G', href: status.href, count: 1 }], + collectionHref: '/api/v3/versions/3/work_packages', + }); + harness.expectNoRowPositions(); + }); + + it('does not use grouped aggregates while the baseline is active', async () => { + const status = { href: '/api/v3/statuses/1' }; + await mount({ + workPackages: [{ id: '1', attributes: { status } }], + pagination: { offset: 1, pageSize: 20, total: 1 }, + groups: [{ value: 'G', href: status.href, count: 1 }], + baseline: true, + }); + harness.expectNoRowPositions(); + }); + + it('withdraws positions while the create section exposes two rows', async () => { + await mount(); + (createRow().parentElement as HTMLTableSectionElement).insertRow().insertCell().textContent = 'Editing'; + harness.table.rowPositions!.paint(); + harness.expectNoRowPositions(); + }); + + it('leaves attributes untouched when values are unchanged', async () => { + await mount(); + const records:MutationRecord[] = []; + const observer = new MutationObserver((batch) => records.push(...batch)); + observer.observe(table(), { subtree: true, attributes: true }); + harness.table.rowPositions!.paint(); + await nextTask(); + observer.disconnect(); + expect(records).toEqual([]); + }); + + it('keeps sibling metadata outside this projection', async () => { + await mount(); + const row = harness.row('21'); + row.setAttribute('aria-posinset', '21'); + row.setAttribute('aria-setsize', '100'); + harness.table.rowPositions!.paint(); + expect(row).toHaveAttribute('aria-posinset', '21'); + expect(row).toHaveAttribute('aria-setsize', '100'); + expect(harness.totalsRow()).not.toHaveAttribute('aria-posinset'); + }); + +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.ts new file mode 100644 index 000000000000..b3ce9469d874 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.ts @@ -0,0 +1,198 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { Injector } from '@angular/core'; +import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service'; +import { WorkPackageViewRelationColumnsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-relation-columns.service'; +import { WorkPackageViewColumnsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-columns.service'; +import { WorkPackageViewBaselineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-baseline.service'; +import type { GroupObject, WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource'; +import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; +import { groupOccurrenceKey, placeholderOccurrenceKey, wpOccurrenceKey, type RenderDraft, type RenderedOccurrence } from '../rendered-occurrence-ledger'; +import type { WorkPackageTable } from '../wp-fast-table'; +import { isEmptyResultsRow, isExposedRow, isInlineCreateRow, isTotalsRow } from '../helpers/structural-rows'; +import { bodyRowPositions, draftAgrees, type BodyRowPositions, type PositionGroup } from './body-row-positions'; + +export interface CapturedRowPositions { + readonly source:WorkPackageCollectionResource; + readonly body:BodyRowPositions; +} + +// Group counts are computed from the unscoped query results, so only the primary collections carry trustworthy aggregates. +const UNSCOPED_COLLECTION_HREF = /\/api\/v3\/(?:(?:projects|workspaces)\/[^/]+\/)?work_packages(?:\?|$)/; + +type ProjectedRowKind = 'ledger'|'skipped'|'empty'|'totals'|'create'|'foreign'; + +function setIfChanged(element:Element, name:string, value:string|null):void { + if (value === null) { + if (element.hasAttribute(name)) element.removeAttribute(name); + } else if (element.getAttribute(name) !== value) { + element.setAttribute(name, value); + } +} + +export class TableRowPositions { + @LazyInject(WorkPackageViewHierarchiesService) private readonly hierarchies:WorkPackageViewHierarchiesService; + + @LazyInject(WorkPackageViewColumnsService) private readonly columns:WorkPackageViewColumnsService; + + @LazyInject(WorkPackageViewRelationColumnsService) private readonly relationColumns:WorkPackageViewRelationColumnsService; + + @LazyInject(WorkPackageViewBaselineService) private readonly baseline:WorkPackageViewBaselineService; + + public readonly injector:Injector = this.table.injector; + + private snapshot:CapturedRowPositions|null = null; + + constructor(private readonly table:WorkPackageTable) {} + + public capture(draft:RenderDraft):CapturedRowPositions|null { + const source = this.table.querySpace.results.value; + if (!source || this.withdrawnByMode() || !this.matchesRenderedRows(source)) return null; + + const groups = this.table.querySpace.groups.value ?? []; + if (groups.length > 0 && !this.trustsGroupedAggregates(source, groups)) return null; + + const positionGroups:PositionGroup[] = groups.map((group, index) => ({ + headerKey: groupOccurrenceKey(index, 'header'), + sumsKey: groupOccurrenceKey(index, 'sums'), + count: group.count, + hasSums: !!group.sums, + })); + const body = bodyRowPositions({ + pageNumber: source.offset, + pageSize: source.pageSize, + total: source.total, + primaryKeys: this.table.originalRows.map(wpOccurrenceKey), + groups: positionGroups, + }); + if (!body) return null; + + const rows = draft.occurrences + .filter((occurrence) => occurrence.key !== placeholderOccurrenceKey()) + .map((occurrence) => ({ key: occurrence.key, member: occurrence.workPackageId !== null })); + return draftAgrees(body, rows, positionGroups) ? { source, body } : null; + } + + public commit(snapshot:CapturedRowPositions|null):void { + this.snapshot = snapshot; + } + + /** Producers call this synchronously after every imperative row move or structural change, so no frame exposes a stale index. */ + public paint():void { + if (this.table.destroyed) return; + const element = this.table.tbody.closest('table'); + if (!element) return; + const rows = Array.from(element.rows); + const projection = this.project(element, rows); + setIfChanged(element, 'aria-rowcount', projection ? String(projection.count) : null); + rows.forEach((row) => { + const position = projection?.indices.get(row); + setIfChanged(row, 'aria-rowindex', position === undefined ? null : String(position)); + }); + } + + private withdrawnByMode():boolean { + if (this.hierarchies.isEnabled) return true; + return (this.columns.hasRelationColumns() || this.columns.hasChildRelationsColumn()) + && Object.keys(this.relationColumns.current).length > 0; + } + + private matchesRenderedRows(source:WorkPackageCollectionResource):boolean { + const { originalRows } = this.table; + return source.count === originalRows.length + && source.elements.length === originalRows.length + && source.elements.every((wp, index) => wp.id === originalRows[index]); + } + + private trustsGroupedAggregates(source:WorkPackageCollectionResource, groups:GroupObject[]):boolean { + return source.groups === groups + && !this.baseline.isActive() + && UNSCOPED_COLLECTION_HREF.test(source.href ?? ''); + } + + private project(element:HTMLTableElement, rows:HTMLTableRowElement[]) { + const snapshot = this.snapshot; + if (!snapshot || snapshot.source !== this.table.querySpace.results.value) return null; + const { body } = snapshot; + const header = element.tHead?.rows; + if (header?.length !== 1) return null; + + const occurrences = new Map(this.table.ledger.occurrences + .filter((occurrence) => occurrence.element !== null) + .map((occurrence) => [occurrence.element!, occurrence])); + const indices = new Map([[header[0], 1]]); + const trailers:HTMLTableRowElement[] = []; + const seen = new Set(); + let emptySurrogate:HTMLTableRowElement|null = null; + for (const row of rows) { + if (row === header[0]) continue; + const kind = this.classify(row, occurrences); + if (kind === 'skipped') continue; + if (kind === 'ledger') { + const ordinal = body.ordinals.get(occurrences.get(row)!.key); + if (ordinal === undefined) return null; + indices.set(row, ordinal + 1); + } else if (kind === 'empty') { + if (body.count !== 0 || emptySurrogate) return null; + emptySurrogate = row; + } else if (kind === 'totals' || kind === 'create') { + if (seen.has(kind)) return null; + seen.add(kind); + trailers.push(row); + } else { + return null; + } + } + + let count = 1 + body.count; + for (const row of [...(emptySurrogate ? [emptySurrogate] : []), ...trailers]) { + count += 1; + indices.set(row, count); + } + if (!Number.isSafeInteger(count)) return null; + + let previous = 0; + for (const row of rows) { + const position = indices.get(row); + if (position === undefined) continue; + if (!Number.isSafeInteger(position) || position <= previous || position > count) return null; + previous = position; + } + return { count, indices }; + } + + private classify(row:HTMLTableRowElement, occurrences:Map):ProjectedRowKind { + const occurrence = occurrences.get(row); + if (occurrence) return occurrence.key === placeholderOccurrenceKey() ? 'skipped' : 'ledger'; + if (!isExposedRow(row)) return 'skipped'; + if (isEmptyResultsRow(row)) return 'empty'; + if (isTotalsRow(this.table, row)) return row.cells.length === 0 ? 'skipped' : 'totals'; + if (isInlineCreateRow(row)) return row.cells.length === 0 ? 'skipped' : 'create'; + return 'foreign'; + } +} diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.ts index 1a5037338748..27fa2a4b482f 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.ts @@ -117,6 +117,9 @@ export type RelationColumnSpec = { id:string, relationType:string }|{ id:string, export interface NonSortableColumnSpec { id:string, sortable:false } export interface TableHarnessOptions { + pagination?:{ offset:number; pageSize:number; total:number }; + /** Self link of the results collection; defaults to the global work packages collection. */ + collectionHref?:string; workPackages:WorkPackageFixture[]; providers?:Provider[]; /** Retains the root so a replacement table can mount with a fresh injector. */ @@ -178,6 +181,10 @@ export interface TableHarness { nextRender():Promise; /** Every rendered work-package row, relation rows included. */ rows():HTMLTableRowElement[]; + /** Asserts the `aria-rowindex` of every table row in document order and the table's `aria-rowcount`; requires `productionMarkup`. */ + expectRowPositions(indices:string[], count:string):void; + /** Asserts that neither the table nor any of its rows carries a position. */ + expectNoRowPositions():void; /** The primary row of a work package, never one of its relation rows. */ row(workPackageId:string):HTMLTableRowElement; /** The relation row of `to` under `from`, located by its occurrence key. */ @@ -301,7 +308,16 @@ export function buildTable(options:TableHarnessOptions):TableHarness { } const rendered = nextRender(); - querySpace.results.putValue({ elements: resources, sumsSchema: { href: sumsSchemaHref } } as WorkPackageCollectionResource); + querySpace.results.putValue({ + elements: resources, + groups: querySpace.groups.value ?? [], + href: options.collectionHref ?? '/api/v3/work_packages', + count: resources.length, + total: options.pagination?.total ?? resources.length, + pageSize: options.pagination?.pageSize ?? Math.max(resources.length, 1), + offset: options.pagination?.offset ?? 1, + sumsSchema: { href: sumsSchemaHref }, + } as WorkPackageCollectionResource); querySpace.initialized.putValue(null); return rendered; @@ -313,6 +329,18 @@ export function buildTable(options:TableHarnessOptions):TableHarness { return Array.from(dom.tbody.querySelectorAll('tr.wp-table--row')); }, + expectRowPositions(indices, count) { + const element = dom.tbody.closest('table')!; + expect(Array.from(element.rows).map((row) => row.getAttribute('aria-rowindex'))).toEqual(indices); + expect(element).toHaveAttribute('aria-rowcount', count); + }, + + expectNoRowPositions() { + const element = dom.tbody.closest('table')!; + expect(element).not.toHaveAttribute('aria-rowcount'); + Array.from(element.rows).forEach((row) => expect(row).not.toHaveAttribute('aria-rowindex')); + }, + row(workPackageId) { const row = dom.tbody.querySelector(`tr.wp-table--row:not(.wp-table--relations-additional-row)[data-work-package-id="${workPackageId}"]`); if (!row) { @@ -338,7 +366,7 @@ export function buildTable(options:TableHarnessOptions):TableHarness { }, totalsRow() { - return productionMarkupElement(dom.wrapper, 'tr.wp-table--sums-row'); + return productionMarkupElement(dom.wrapper, '.work-package-table > tbody:not(.work-package--results-tbody) > tr.wp-table--sums-row'); }, timelineRow(workPackageId) { @@ -510,7 +538,10 @@ export function harnessProviders(dragService:FakeDragAndDropService, options:Tab requireAll: options.requireAll ?? ((ids:string[]) => Promise.resolve( ids.map((id) => states.workPackages.get(id).value!).filter(Boolean), )), - cache: { current: (_id:string, fallback:unknown) => fallback }, + cache: { + current: (_id:string, fallback:unknown) => fallback, + state: (id:string) => states.workPackages.get(id), + }, id: (id:string) => ({ get: () => of(states.workPackages.get(id).value), requireAndStream: () => of(states.workPackages.get(id).value), diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts index e5cf0c618d60..7b81a121f7c1 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts @@ -49,11 +49,13 @@ import { type OccurrenceKey, RenderedOccurrenceLedger } from './rendered-occurre import { WorkPackageTableEditingContext } from './wp-table-editing'; import type { WorkPackageTableRow } from './wp-table.interfaces'; import type { KeyboardNavigation } from './handlers/state/keyboard/keyboard-navigation-transformer'; -import { TreegridSemantics } from './semantics/treegrid-semantics'; +import { TableRowPositions, type CapturedRowPositions } from './semantics/table-row-positions'; +import { paintSectionExposure, TreegridSemantics } from './semantics/treegrid-semantics'; interface PendingRender { readonly pass:PrimaryRenderPass; readonly timeline:boolean; + readonly positions:CapturedRowPositions|null; } export class WorkPackageTable { @@ -100,6 +102,8 @@ export class WorkPackageTable { public readonly semantics:TreegridSemantics|null = this.configuration.keyboardNavigationEnabled ? new TreegridSemantics(this) : null; + public readonly rowPositions:TableRowPositions|null = this.configuration.keyboardNavigationEnabled ? new TableRowPositions(this) : null; + private readonly rowsRefreshedSubject = new Subject(); /** Emits a work package id after `refreshRows` replaced at least one of its rendered rows, or an edge row key after that row re-rendered. */ @@ -252,9 +256,17 @@ export class WorkPackageTable { } public notifyRowsRefreshed(key:string):void { + this.rowPositions?.paint(); this.rowsRefreshedSubject.next(key); } + /** Re-exposes a structural section whose rows changed outside the render pass. */ + public reconcileSection(section:HTMLTableSectionElement):void { + if (!this.semantics) return; + paintSectionExposure(section); + this.rowPositions?.paint(); + } + /** * Determine whether we need an empty placeholder row. * When D&D is enabled, the table requires a drag target that is non-empty, @@ -268,7 +280,8 @@ export class WorkPackageTable { this.keyboardNavigation?.captureOwnership(); this.editing.reset(); const promoted = timeline || this.pendingRender?.timeline === true; - const pending:PendingRender = { pass: this.rowBuilder.buildRows({ timeline: promoted }), timeline: promoted }; + const pass = this.rowBuilder.buildRows({ timeline: promoted }); + const pending:PendingRender = { pass, timeline: promoted, positions: this.rowPositions?.capture(pass.draft) ?? null }; this.pendingRender = pending; this.uiWork.frame(() => this.commitRender(pending)); } @@ -283,12 +296,14 @@ export class WorkPackageTable { this.timelineBody.replaceChildren(timelinePass.timelineBody); } this.ledger.commit(pass.draft); + this.rowPositions?.commit(pending.positions); this.lastRenderPass = pass; this.publishRendered(); } private publishRendered():void { this.semantics?.paint(); + this.rowPositions?.paint(); this.querySpace.tableRendered.putValue(this.ledger.snapshot()); } diff --git a/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.ts b/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.ts index 27b5b034eb3c..efda1990a0f5 100644 --- a/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.ts @@ -57,7 +57,6 @@ import { HalResourceEditingService, } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { delegate, DelegateEvent } from '@knowledgecode/delegate'; -import { paintSectionExposure } from '../wp-fast-table/semantics/treegrid-semantics'; @Component({ // eslint-disable-next-line @angular-eslint/component-selector @@ -237,7 +236,7 @@ export class WorkPackageInlineCreateComponent extends UntilDestroyedMixin implem } public addWorkPackageRow() { - this.wpCreate + void this.wpCreate .createOrContinueWorkPackage(this.projectIdentifier) .then((change:WorkPackageChangeset) => { const wp = this.currentWorkPackage = change.projectedResource; @@ -349,7 +348,7 @@ export class WorkPackageInlineCreateComponent extends UntilDestroyedMixin implem private reconcileExposure():void { if (this.treegrid) { - paintSectionExposure(this.element); + this.table.reconcileSection(this.element); } } } diff --git a/frontend/src/app/features/work-packages/components/wp-table/wp-table-sums-row/wp-table-sums-row.directive.spec.ts b/frontend/src/app/features/work-packages/components/wp-table/wp-table-sums-row/wp-table-sums-row.directive.spec.ts index f56f699d9220..544a7727a693 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/wp-table-sums-row/wp-table-sums-row.directive.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/wp-table-sums-row/wp-table-sums-row.directive.spec.ts @@ -243,6 +243,25 @@ describe('WorkPackageTableSumsRowController semantics', () => { vi.restoreAllMocks(); }); + it('repaints positions once per update and before the row notification', async () => { + const harness = await setUp({ keyboardNavigationEnabled: true }); + const events:string[] = []; + vi.spyOn(harness.table.rowPositions!, 'paint').mockImplementation(() => { + events.push(`paint:${row.getAttribute('aria-hidden')}:${row.cells.length > 0}`); + expect(row).toHaveAttribute('role', 'row'); + }); + harness.table.rowsRefreshed$.subscribe(() => events.push('notify')); + attach(harness, true); + events.length = 0; + + await resolveSchema(); + + expect(events).toEqual(['paint:null:true', 'notify']); + events.length = 0; + directive.wpTableSums.setEnabled(false); + expect(events).toEqual(['paint:true:false', 'notify']); + }); + it('paints roles and exposure before notifying the table', async () => { const harness = await setUp({ keyboardNavigationEnabled: true }); const painted = firstValueFrom(harness.table.rowsRefreshed$.pipe(take(1), map((key) => ({ @@ -291,15 +310,34 @@ describe('WorkPackageTableSumsRowController semantics', () => { expect(section()).toHaveAttribute('aria-hidden', 'true'); }); + it('repaints only the replacement table after a pending schema resolves', async () => { + const previous = await setUp({ keyboardNavigationEnabled: true }); + attach(previous, true); + const previousPaint = vi.spyOn(previous.table.rowPositions!, 'paint'); + const replacement = buildTable({ workPackages: [], configuration: { keyboardNavigationEnabled: true } }); + tables.push(replacement); + directive.workPackageTable = replacement.table; + const replacementPaint = vi.spyOn(replacement.table.rowPositions!, 'paint'); + previous.table.destroy(); + + await resolveSchema(); + + expect(previousPaint).not.toHaveBeenCalled(); + expect(replacementPaint).toHaveBeenCalled(); + expect(row).not.toHaveAttribute('aria-hidden'); + }); + it('stays hidden when the schema resolves after sums were disabled', async () => { const harness = await setUp({ keyboardNavigationEnabled: true }); const refreshed = vi.fn(); harness.table.rowsRefreshed$.subscribe(refreshed); attach(harness, true); directive.wpTableSums.setEnabled(false); + const painted = vi.spyOn(harness.table.rowPositions!, 'paint'); await resolveSchema(); + expect(painted).not.toHaveBeenCalled(); expect(row).toBeEmptyDOMElement(); expect(row).toHaveAttribute('aria-hidden', 'true'); expect(section()).toHaveAttribute('aria-hidden', 'true'); @@ -310,9 +348,11 @@ describe('WorkPackageTableSumsRowController semantics', () => { const harness = await setUp({ keyboardNavigationEnabled: true }); attach(harness, true); harness.table.destroy(); + const painted = vi.spyOn(harness.table.rowPositions!, 'paint'); await resolveSchema(); + expect(painted).not.toHaveBeenCalled(); expect(row).toBeEmptyDOMElement(); expect(row).toHaveAttribute('aria-hidden', 'true'); expect(section()).toHaveAttribute('aria-hidden', 'true'); diff --git a/frontend/src/app/features/work-packages/components/wp-table/wp-table-sums-row/wp-table-sums-row.directive.ts b/frontend/src/app/features/work-packages/components/wp-table/wp-table-sums-row/wp-table-sums-row.directive.ts index d47959defebc..5c8ca0d1fb73 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/wp-table-sums-row/wp-table-sums-row.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/wp-table-sums-row/wp-table-sums-row.directive.ts @@ -38,7 +38,6 @@ import { combineLatest, ReplaySubject } from 'rxjs'; import { GroupSumsBuilder } from 'core-app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder'; import { WorkPackageTable } from 'core-app/features/work-packages/components/wp-fast-table/wp-fast-table'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; -import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource'; import { totalsRowKey } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/navigable-rows'; import { keyboardNavigationEnabled } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; @@ -115,24 +114,28 @@ export class WorkPackageTableSumsRowController implements AfterViewInit { if (sum && resource.sumsSchema) { void this.schemaCache .ensureLoaded(resource.sumsSchema.href!) - .then((schema:SchemaResource) => { + .then(() => { if (table.destroyed || this.destroyRef.destroyed || this.workPackageTable !== table || !this.wpTableSums.isEnabled) return; table.keyboardNavigation?.captureOwnership(); - this.refresh(columns, resource, schema); - this.reconcileSemantics(table); + this.refresh(columns, resource); + this.paintSemantics(table); table.notifyRowsRefreshed(totalsRowKey); }); } else if (this.element.hasChildNodes()) { table.keyboardNavigation?.captureOwnership(); this.clear(); - this.reconcileSemantics(table); + this.paintSemantics(table); table.notifyRowsRefreshed(totalsRowKey); } }); } private reconcileSemantics(table:WorkPackageTable) { - if (table.destroyed || !keyboardNavigationEnabled(table.configuration)) return; + if (this.paintSemantics(table)) table.rowPositions?.paint(); + } + + private paintSemantics(table:WorkPackageTable):boolean { + if (table.destroyed || !keyboardNavigationEnabled(table.configuration)) return false; if (this.isHidden || this.element.cells.length === 0) { this.element.setAttribute('aria-hidden', 'true'); @@ -141,18 +144,19 @@ export class WorkPackageTableSumsRowController implements AfterViewInit { } paintRowRole(this.element, 1); paintSectionExposure(this.element.parentElement as HTMLTableSectionElement); + return true; } private clear() { this.element.innerHTML = ''; } - private refresh(columns:QueryColumn[], resource:WorkPackageCollectionResource, schema:SchemaResource) { + private refresh(columns:QueryColumn[], resource:WorkPackageCollectionResource) { this.clear(); - this.render(columns, resource, schema); + this.render(columns, resource); } - private render(columns:QueryColumn[], resource:WorkPackageCollectionResource, schema:SchemaResource) { + private render(columns:QueryColumn[], resource:WorkPackageCollectionResource) { this.groupSumsBuilder = new GroupSumsBuilder(this.injector, this.workPackageTable); this.groupSumsBuilder.text = this.text; this.groupSumsBuilder.renderColumns(resource.totalSums!, this.element, 'total'); diff --git a/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.spec.ts b/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.spec.ts index 4626a109819d..49f534298270 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.spec.ts @@ -498,7 +498,7 @@ describe('WorkPackagesTableComponent treegrid structure', () => { function putResults(fixtures:WorkPackageFixture[]) { const resources = fixtures.map(buildWorkPackage); resources.forEach((wp) => states.workPackages.get(wp.id!).putValue(wp)); - querySpace.results.putValue({ elements: resources, count: resources.length, total: resources.length } as unknown as WorkPackageCollectionResource); + querySpace.results.putValue({ elements: resources, offset: 1, pageSize: 20, count: resources.length, total: resources.length } as unknown as WorkPackageCollectionResource); } function tables():WorkPackagesTableComponent[] { @@ -592,6 +592,19 @@ describe('WorkPackagesTableComponent treegrid structure', () => { }); }); + it('repaints positions after Angular updates the header', async () => { + const component = await mount(enabled); + const painted:string[] = []; + vi.spyOn(component.workPackageTable.rowPositions!, 'paint').mockImplementation(() => { + painted.push(headerRow().getAttribute('aria-label') ?? headerRow().textContent ?? ''); + expect(headerRow().querySelector('[aria-sort="descending"]')).not.toBeNull(); + }); + + sortBy(['subject', QUERY_SORT_BY_DESC]); + + expect(painted).not.toHaveLength(0); + }); + describe('header structure', () => { it('exposes a level 1 header row inside a rowgroup', async () => { await mount(enabled); @@ -792,6 +805,101 @@ describe('WorkPackagesTableComponent treegrid structure', () => { expect(within(row).getAllByRole('button')).toHaveLength(2); }); + it('repaints positions after permission and reference template changes', async () => { + canReference.next(true); + const component = await mount(inlineCreate); + const painted = vi.spyOn(component.workPackageTable.rowPositions!, 'paint'); + painted.mockImplementation(() => { + expect(createSection()).not.toHaveAttribute('aria-hidden'); + }); + + fireEvent.click(within(createSection()).getByText('Reference an existing work package')); + expect(painted).toHaveBeenCalled(); + expect(within(createSection()).getByRole('textbox', { name: 'Work package to reference' })).toBeInTheDocument(); + const exposure:(string|null)[] = []; + painted.mockImplementation(() => exposure.push(createSection().getAttribute('aria-hidden'))); + canAdd.next(false); + canReference.next(false); + expect(exposure).toEqual([null, 'true']); + }); + + it('updates absolute positions through create and permission changes', async () => { + const component = await mount(inlineCreate); + const table = component.workPackageTable.tbody.closest('table')!; + expect(table).toHaveAttribute('aria-rowcount', '4'); + expect(within(createSection()).getByRole('row')).toHaveAttribute('aria-rowindex', '4'); + + const row = await openCreateRow(); + expect(table).toHaveAttribute('aria-rowcount', '4'); + expect(row).toHaveAttribute('aria-rowindex', '4'); + const editor = within(row).getByRole('textbox'); + editor.focus(); + component.workPackageTable.rowPositions!.paint(); + expect(document.activeElement).toBe(editor); + + fireEvent.keyDown(editor, { key: 'Escape' }); + await waitFor(() => expectExposed()); + expect(within(createSection()).getByRole('row')).toHaveAttribute('aria-rowindex', '4'); + canAdd.next(false); + expect(table).toHaveAttribute('aria-rowcount', '3'); + expect(createSection()).toHaveAttribute('aria-hidden', 'true'); + }); + + it('withdraws positions while the button row and the editing row coexist', async () => { + const component = await mount(inlineCreate); + const table = component.workPackageTable.tbody.closest('table')!; + const positions = component.workPackageTable.rowPositions!; + const paint = positions.paint.bind(positions); + const counts:(string|null)[] = []; + vi.spyOn(positions, 'paint').mockImplementation(() => { + paint(); + if (createSection().querySelectorAll('tr').length === 2) counts.push(table.getAttribute('aria-rowcount')); + }); + + await openCreateRow(); + + expect(counts).not.toHaveLength(0); + expect(new Set(counts)).toEqual(new Set([null])); + }); + + it('stops repainting a removed table on permission changes', async () => { + const component = await mount(inlineCreate); + const painted = vi.spyOn(component.workPackageTable.rowPositions!, 'paint'); + const listView = fixture as ComponentFixture; + listView.componentInstance.showTable.set(false); + listView.detectChanges(); + painted.mockClear(); + + canAdd.next(false); + canReference.next(true); + await nextTask(); + + expect(painted).not.toHaveBeenCalled(); + }); + + it('repaints positions after imperative create append and removal', async () => { + const component = await mount(inlineCreate); + const painted = vi.spyOn(component.workPackageTable.rowPositions!, 'paint'); + painted.mockImplementation(() => { + expect(createRow()).toBeInTheDocument(); + }); + const row = await openCreateRow(); + expect(painted).toHaveBeenCalled(); + const editor = within(row).getByRole('textbox'); + editor.focus(); + const focused = document.activeElement; + painted.mockRestore(); + component.workPackageTable.rowPositions!.paint(); + expect(document.activeElement).toBe(focused); + + const removed = vi.spyOn(component.workPackageTable.rowPositions!, 'paint').mockImplementation(() => { + expect(createRow()).not.toBeInTheDocument(); + }); + fireEvent.keyDown(editor, { key: 'Escape' }); + expect(removed).toHaveBeenCalled(); + await waitFor(() => expectExposed()); + }); + it('renders the active reference row with the same semantics', async () => { canReference.next(true); await mount(inlineCreate); diff --git a/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.ts b/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.ts index f58bd8fd808c..11f09f1196a4 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.ts @@ -238,7 +238,7 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O this.scrollSync.update(timelines.visible); } - this.cdRef.detectChanges(); + this.renderHeader(); }); this.cdRef.detectChanges(); @@ -293,7 +293,12 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O hover.activate(); scroll.update(this.timelineVisible); + this.renderHeader(); + } + + private renderHeader():void { this.cdRef.detectChanges(); + this.workPackageTable?.rowPositions?.paint(); } public get isEmbedded() { diff --git a/spec/features/work_packages/pagination_spec.rb b/spec/features/work_packages/pagination_spec.rb index fa83c973fe86..092ce417ec36 100644 --- a/spec/features/work_packages/pagination_spec.rb +++ b/spec/features/work_packages/pagination_spec.rb @@ -39,6 +39,8 @@ shared_examples_for "paginated work package list" do let!(:work_package_1) { create(:work_package, project:) } let!(:work_package_2) { create(:work_package, project:) } + let(:hierarchies) { Components::WorkPackages::Hierarchies.new } + let(:pagination) { Components::TablePagination.new } before do login_as(admin) @@ -58,30 +60,31 @@ expect(page).to have_content(work_package_1.subject) expect(page).to have_no_content(work_package_2.subject) end + hierarchies.disable_via_header + wp_table.expect_row_positions(rowcount: 4, rows: [1, 2, 4]) - within(".op-pagination--pages") do - find(".op-pagination--item button", text: "2").click - end + pagination.go_to_page(2) within(".work-packages-partitioned-query-space--container") do expect(page).to have_content(work_package_2.subject) expect(page).to have_no_content(work_package_1.subject) end + wp_table.expect_row_positions(rowcount: 4, rows: [1, 3, 4]) - within(".op-pagination--options") do - find(".op-pagination--item button", text: "50").click - end + pagination.select_per_page(50) within(".work-packages-partitioned-query-space--container") do expect(page).to have_content(work_package_1.subject) expect(page).to have_content(work_package_2.subject) end + wp_table.expect_row_positions(rowcount: 4, rows: [1, 2, 3, 4]) end end context "with project scope" do it_behaves_like "paginated work package list" do let(:path) { project_work_packages_path(project) } + let(:wp_table) { Pages::WorkPackagesTable.new(project) } let(:expected_path) { "/projects/project1/work_packages" } end end @@ -89,6 +92,7 @@ context "globally" do it_behaves_like "paginated work package list" do let(:path) { work_packages_path } + let(:wp_table) { Pages::WorkPackagesTable.new } let(:expected_path) { "/work_packages" } end end diff --git a/spec/features/work_packages/sorting/manual_sorting_spec.rb b/spec/features/work_packages/sorting/manual_sorting_spec.rb index e1f616f6215e..99c444815f3c 100644 --- a/spec/features/work_packages/sorting/manual_sorting_spec.rb +++ b/spec/features/work_packages/sorting/manual_sorting_spec.rb @@ -376,6 +376,24 @@ def expect_query_order(query, expected) wp_table.expect_work_package_order work_package3, work_package1, work_package2, work_package4 end + it "exposes row positions again once a drop has reloaded the query" do + wp_table.expect_row_positions(rowcount: 6, rows: [1, 2, 3, 4, 5, 6]) + + wp_table.drag_and_drop_work_package from: 1, to: 3 + wp_table.expect_work_package_order work_package1, work_package3, work_package2, work_package4 + wp_table.expect_and_dismiss_toaster message: "Successful creation." + wp_table.expect_row_positions(rowcount: 6, rows: [1, 2, 3, 4, 5, 6]) + + query = Query.last + wp_table.drag_and_drop_work_package from: 0, to: 2 + wp_table.expect_work_package_order work_package3, work_package1, work_package2, work_package4 + expect_query_order(query, [work_package3.id, work_package1.id, work_package2.id]) + + wp_table.visit_query query + wp_table.expect_work_package_order work_package3, work_package1, work_package2, work_package4 + wp_table.expect_row_positions(rowcount: 6, rows: [1, 2, 3, 4, 5, 6]) + end + it "keeps the source row visible as a placeholder and collapses multi-selection" do # Multi-select rows 1 and 2 via ctrl-click on the ID cells (established pattern). # loading_indicator_saveguard between the two clicks mirrors diff --git a/spec/features/work_packages/table/treegrid_semantics_spec.rb b/spec/features/work_packages/table/treegrid_semantics_spec.rb index 738c74ef4924..8c75aeba5551 100644 --- a/spec/features/work_packages/table/treegrid_semantics_spec.rb +++ b/spec/features/work_packages/table/treegrid_semantics_spec.rb @@ -69,37 +69,54 @@ it "exposes rows, sort, totals, selection and the relation button" do wp_table.visit_query(query) wp_table.expect_work_package_order(child, parent) - - expect(treegrid).to match_selector(:table, accessible_description: "Sorted by Subject ascending.", exact: true) - expect(treegrid).to have_selector(:row, "Subject", aria: { level: 1, selected: nil, expanded: nil }) - expect(treegrid).to have_selector(:columnheader, "Subject", aria: { sort: "ascending" }) - expect(treegrid).to have_selector(:columnheader, "ID", aria: { sort: nil }) - expect(treegrid).to have_selector(:row, "Parent task", aria: { level: 1, selected: false, expanded: nil }) - expect(treegrid).to have_selector(:row, accessible_name: /Total sum/, aria: { level: 1, selected: nil }) - expect(treegrid).to have_selector(:gridcell, accessible_description: "Total sum") + wp_table.expect_row_positions(rowcount: 5, rows: [1, 2, 3, 4, 5]) + + expect(wp_table.treegrid).to match_selector(:table, accessible_description: "Sorted by Subject ascending.", exact: true) + expect(wp_table.treegrid).to have_selector(:row, "Subject", aria: { level: 1, selected: nil, expanded: nil }) + expect(wp_table.treegrid).to have_selector(:columnheader, "Subject", aria: { sort: "ascending" }) + expect(wp_table.treegrid).to have_selector(:columnheader, "ID", aria: { sort: nil }) + expect(wp_table.treegrid).to have_selector(:row, "Parent task", aria: { level: 1, selected: false, expanded: nil }) + expect(wp_table.treegrid).to have_selector(:row, accessible_name: /Total sum/, aria: { level: 1, selected: nil }) + expect(wp_table.treegrid).to have_selector(:gridcell, accessible_description: "Total sum") expect_axe_clean_table wp_table.focus_row_by_keyboard(parent) wp_table.press_keys(:enter) - expect(treegrid).to have_selector(:row, "Parent task", aria: { selected: true }) - expect(treegrid).to have_selector(:row, "Child task", aria: { selected: false }) + expect(wp_table.treegrid).to have_selector(:row, "Parent task", aria: { selected: true }) + expect(wp_table.treegrid).to have_selector(:row, "Child task", aria: { selected: false }) expect_axe_clean_table relations = "Relations in Children for #{parent.formatted_id}" find(:button, accessible_name: relations, aria: { expanded: false }).click expect(page).to have_button(accessible_name: relations, aria: { expanded: true }) - expect(treegrid).to have_selector(:row, "Child task", aria: { level: 2, selected: false }, count: 1) - expect(treegrid).to have_selector(:row, "Parent task", aria: { selected: true }) + expect(wp_table.treegrid).to have_selector(:row, "Child task", aria: { level: 2, selected: false }, count: 1) + expect(wp_table.treegrid).to have_selector(:row, "Parent task", aria: { selected: true }) + wp_table.expect_no_row_positions expect_axe_clean_table find(:button, accessible_name: relations, aria: { expanded: true }).click expect(page).to have_button(accessible_name: relations, aria: { expanded: false }) - expect(treegrid).to have_no_selector(:element, "tr", aria: { level: 2 }, text: "Child task", visible: :all) + expect(wp_table.treegrid).to have_no_selector(:element, "tr", aria: { level: 2 }, text: "Child task", visible: :all) + wp_table.expect_row_positions(rowcount: 5, rows: [1, 2, 3, 4, 5]) expect_axe_clean_table expect(page).to have_selector(:element, class: "work-packages-tabletimeline--timeline-side", aria: { hidden: true }, visible: :all) end + + it "keeps the positions while the inline create row is open and after it is cancelled" do + wp_table.visit_query(query) + wp_table.expect_work_package_order(child, parent) + wp_table.expect_row_positions(rowcount: 5, rows: [1, 2, 3, 4, 5]) + + wp_table.click_inline_create + expect(wp_table.creation_row).to match_selector(:row, aria: { rowindex: 5 }) + wp_table.expect_row_positions(rowcount: 5, rows: [1, 2, 3, 4, 5]) + + wp_table.press_keys(:escape) + expect(page).to have_button("Create new work package") + wp_table.expect_row_positions(rowcount: 5, rows: [1, 2, 3, 4, 5]) + end end context "in grouped mode" do @@ -116,34 +133,37 @@ it "exposes group levels and removes collapsed members from the accessibility tree" do wp_table.visit_query(query) wp_table.expect_work_package_listed(alpha, bravo, charlie) + wp_table.expect_row_positions(rowcount: 10, rows: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) - expect(treegrid).to have_selector(:row, "Foo", aria: { level: 1, expanded: true, selected: nil }) - expect(treegrid).to have_selector(:row, "Alpha in foo", aria: { level: 2, selected: false, expanded: nil }) - expect(treegrid).to have_selector(:row, /\bSum\b/, aria: { level: 2, selected: nil, expanded: nil }, count: 2) - expect(treegrid).to have_selector(:gridcell, accessible_description: "Sum", minimum: 2) - expect(treegrid).to have_selector(:row, accessible_name: /Total sum/, aria: { level: 1, selected: nil }) + expect(wp_table.treegrid).to have_selector(:row, "Foo", aria: { level: 1, expanded: true, selected: nil }) + expect(wp_table.treegrid).to have_selector(:row, "Alpha in foo", aria: { level: 2, selected: false, expanded: nil }) + expect(wp_table.treegrid).to have_selector(:row, /\bSum\b/, aria: { level: 2, selected: nil, expanded: nil }, count: 2) + expect(wp_table.treegrid).to have_selector(:gridcell, accessible_description: "Sum", minimum: 2) + expect(wp_table.treegrid).to have_selector(:row, accessible_name: /Total sum/, aria: { level: 1, selected: nil }) expect_axe_clean_table wp_table.focus_row_by_keyboard(alpha) wp_table.press_keys(:enter) - expect(treegrid).to have_selector(:row, "Alpha in foo", aria: { selected: true }) + expect(wp_table.treegrid).to have_selector(:row, "Alpha in foo", aria: { selected: true }) expect_axe_clean_table - foo_header = treegrid.find(:row, "Foo", aria: { expanded: true }) + foo_header = wp_table.treegrid.find(:row, "Foo", aria: { expanded: true }) foo_sums_key = foo_header["data-occurrence-key"].sub(/:header\z/, ":sums") foo_header.find(".expander").click - expect(treegrid).to have_selector(:row, "Foo", aria: { level: 1, expanded: false }) - foo_members = [alpha, bravo].map { |wp| treegrid.find(wp_table.row_selector(wp), visible: :hidden) } - foo_sum = treegrid.find("tr[data-occurrence-key='#{foo_sums_key}']", visible: :hidden) + expect(wp_table.treegrid).to have_selector(:row, "Foo", aria: { level: 1, expanded: false }) + foo_members = [alpha, bravo].map { |wp| wp_table.treegrid.find(wp_table.row_selector(wp), visible: :hidden) } + foo_sum = wp_table.treegrid.find("tr[data-occurrence-key='#{foo_sums_key}']", visible: :hidden) expect(foo_members.first).to match_selector(:element, aria: { hidden: true, selected: true }, visible: :hidden) [*foo_members, foo_sum].each { |row| expect_excluded_from_accessibility_tree(row) } - expect(treegrid).to have_selector(:row, "Charlie in bar", aria: { level: 2 }) + expect(wp_table.treegrid).to have_selector(:row, "Charlie in bar", aria: { level: 2 }) + wp_table.expect_row_positions(rowcount: 10, rows: [1, 2, 3, 4, 5, 9, 10]) expect_axe_clean_table - treegrid.find(:row, "Foo", aria: { expanded: false }).find(".expander").click - expect(treegrid).to have_selector(:row, "Foo", aria: { level: 1, expanded: true }) - expect(treegrid).to have_selector(:row, "Alpha in foo", aria: { level: 2, selected: true }) + wp_table.treegrid.find(:row, "Foo", aria: { expanded: false }).find(".expander").click + expect(wp_table.treegrid).to have_selector(:row, "Foo", aria: { level: 1, expanded: true }) + expect(wp_table.treegrid).to have_selector(:row, "Alpha in foo", aria: { level: 2, selected: true }) [*foo_members, foo_sum].each { |row| expect_exposed_at_level(row, 2) } + wp_table.expect_row_positions(rowcount: 10, rows: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) expect_axe_clean_table end @@ -153,22 +173,22 @@ wp_table.focus_row_by_keyboard(charlie) wp_table.press_keys(:up) - expect(treegrid).to have_selector(:row, "Bar", aria: { level: 1, expanded: true }, focused: true) + expect(wp_table.treegrid).to have_selector(:row, "Bar", aria: { level: 1, expanded: true }, focused: true) wp_table.press_keys(:left) - expect(treegrid).to have_selector(:row, "Bar", aria: { level: 1, expanded: false }, focused: true) - charlie_row = treegrid.find(wp_table.row_selector(charlie), visible: :hidden) + expect(wp_table.treegrid).to have_selector(:row, "Bar", aria: { level: 1, expanded: false }, focused: true) + charlie_row = wp_table.treegrid.find(wp_table.row_selector(charlie), visible: :hidden) expect_excluded_from_accessibility_tree(charlie_row) expect_axe_clean_table wp_table.press_keys(:right) - expect(treegrid).to have_selector(:row, "Bar", aria: { level: 1, expanded: true }, focused: true) + expect(wp_table.treegrid).to have_selector(:row, "Bar", aria: { level: 1, expanded: true }, focused: true) expect_exposed_at_level(charlie_row, 2) wp_table.press_keys(:enter) - expect(treegrid).to have_selector(:row, "Bar", aria: { level: 1, expanded: false }, focused: true) + expect(wp_table.treegrid).to have_selector(:row, "Bar", aria: { level: 1, expanded: false }, focused: true) wp_table.press_keys(:enter) - expect(treegrid).to have_selector(:row, "Bar", aria: { level: 1, expanded: true }, focused: true) + expect(wp_table.treegrid).to have_selector(:row, "Bar", aria: { level: 1, expanded: true }, focused: true) expect_axe_clean_table end end @@ -190,9 +210,35 @@ wp_table.expect_work_package_listed(alpha, bravo, charlie) expect(page).to have_css(".wp-table--drag-and-drop-handle", visible: :all) - expect(treegrid).to have_selector(:row, /\bSum\b/, aria: { level: 2, selected: nil, expanded: nil }, count: 2) - expect(treegrid).to have_selector(:gridcell, accessible_description: "Sum", minimum: 2) - expect(treegrid).to have_selector(:gridcell, accessible_description: "Total sum") + expect(wp_table.treegrid).to have_selector(:row, /\bSum\b/, aria: { level: 2, selected: nil, expanded: nil }, count: 2) + expect(wp_table.treegrid).to have_selector(:gridcell, accessible_description: "Sum", minimum: 2) + expect(wp_table.treegrid).to have_selector(:gridcell, accessible_description: "Total sum") + expect_axe_clean_table + end + end + + context "in grouped mode across pages", with_settings: { per_page_options: "3" } do + let(:foo) { create(:category, project:, name: "Foo") } + let(:bar) { create(:category, project:, name: "Bar") } + let!(:alpha) { create(:work_package, project:, category: foo, subject: "Alpha in foo", estimated_hours: 1) } + let!(:bravo) { create(:work_package, project:, category: foo, subject: "Bravo in foo", estimated_hours: 2) } + let!(:charlie) { create(:work_package, project:, category: bar, subject: "Charlie in bar", estimated_hours: 4) } + let!(:delta) { create(:work_package, project:, category: foo, subject: "Delta in foo", estimated_hours: 8) } + let!(:echo) { create(:work_package, project:, category: foo, subject: "Echo in foo", estimated_hours: 16) } + let!(:query) do + create(:query, user:, project:, column_names: %w[id subject estimated_hours], + sort_criteria: [%w[subject asc]], group_by: "category", show_hierarchies: false, + display_sums: true) + end + + it "continues the numbering on the next page and keeps the gap for the members of the other page" do + wp_table.visit_query(query) + wp_table.expect_work_package_order(charlie, alpha, bravo) + wp_table.expect_row_positions(rowcount: 12, rows: [1, 2, 3, 4, 5, 6, 7, 10, 11, 12]) + + within(".op-pagination--pages") { click_on "2", exact: true } + wp_table.expect_work_package_order(delta, echo) + wp_table.expect_row_positions(rowcount: 12, rows: [1, 5, 8, 9, 10, 11, 12]) expect_axe_clean_table end end @@ -209,33 +255,34 @@ it "exposes levels and expansion and removes collapsed descendants from the accessibility tree" do wp_table.visit_query(query) wp_table.expect_work_package_listed(parent, child, grandchild, sibling) + wp_table.expect_no_row_positions - expect(treegrid).to have_selector(:row, "Alpha parent", aria: { level: 1, expanded: true, selected: false }) - expect(treegrid).to have_selector(:row, "Bravo child", aria: { level: 2, expanded: true, selected: false }) - expect(treegrid).to have_selector(:row, "Charlie grandchild", - aria: { level: 3, expanded: nil, selected: false }) - expect(treegrid).to have_selector(:row, "Delta sibling", aria: { level: 1, expanded: nil, selected: false }) + expect(wp_table.treegrid).to have_selector(:row, "Alpha parent", aria: { level: 1, expanded: true, selected: false }) + expect(wp_table.treegrid).to have_selector(:row, "Bravo child", aria: { level: 2, expanded: true, selected: false }) + expect(wp_table.treegrid).to have_selector(:row, "Charlie grandchild", + aria: { level: 3, expanded: nil, selected: false }) + expect(wp_table.treegrid).to have_selector(:row, "Delta sibling", aria: { level: 1, expanded: nil, selected: false }) expect_axe_clean_table wp_table.focus_row_by_keyboard(grandchild) wp_table.press_keys(:enter) - expect(treegrid).to have_selector(:row, "Charlie grandchild", aria: { selected: true }) + expect(wp_table.treegrid).to have_selector(:row, "Charlie grandchild", aria: { selected: true }) expect_axe_clean_table - within(treegrid.find(:row, "Alpha parent", aria: { expanded: true })) do + within(wp_table.treegrid.find(:row, "Alpha parent", aria: { expanded: true })) do find(:role, "button", accessible_name: /\AHierarchy level 1, expanded\./).click end - expect(treegrid).to have_selector(:row, "Alpha parent", aria: { level: 1, expanded: false }) - descendants = [child, grandchild].map { |wp| treegrid.find(wp_table.row_selector(wp), visible: :hidden) } + expect(wp_table.treegrid).to have_selector(:row, "Alpha parent", aria: { level: 1, expanded: false }) + descendants = [child, grandchild].map { |wp| wp_table.treegrid.find(wp_table.row_selector(wp), visible: :hidden) } expect(descendants.last).to match_selector(:element, aria: { hidden: true, selected: true }, visible: :hidden) descendants.each { |row| expect_excluded_from_accessibility_tree(row) } expect_axe_clean_table - within(treegrid.find(:row, "Alpha parent", aria: { expanded: false })) do + within(wp_table.treegrid.find(:row, "Alpha parent", aria: { expanded: false })) do find(:role, "button", accessible_name: /\AHierarchy level 1, collapsed\./).click end - expect(treegrid).to have_selector(:row, "Alpha parent", aria: { level: 1, expanded: true }) - expect(treegrid).to have_selector(:row, "Charlie grandchild", aria: { level: 3, selected: true }) + expect(wp_table.treegrid).to have_selector(:row, "Alpha parent", aria: { level: 1, expanded: true }) + expect(wp_table.treegrid).to have_selector(:row, "Charlie grandchild", aria: { level: 3, selected: true }) expect_exposed_at_level(descendants.first, 2) expect_exposed_at_level(descendants.last, 3) expect_axe_clean_table @@ -247,14 +294,14 @@ wp_table.focus_row_by_keyboard(parent) wp_table.press_keys(:left) - expect(treegrid).to have_selector(:row, "Alpha parent", aria: { level: 1, expanded: false }, focused: true) - descendants = [child, grandchild].map { |wp| treegrid.find(wp_table.row_selector(wp), visible: :hidden) } + expect(wp_table.treegrid).to have_selector(:row, "Alpha parent", aria: { level: 1, expanded: false }, focused: true) + descendants = [child, grandchild].map { |wp| wp_table.treegrid.find(wp_table.row_selector(wp), visible: :hidden) } descendants.each { |row| expect_excluded_from_accessibility_tree(row) } - expect(treegrid).to have_selector(:row, "Delta sibling", aria: { level: 1 }) + expect(wp_table.treegrid).to have_selector(:row, "Delta sibling", aria: { level: 1 }) expect_axe_clean_table wp_table.press_keys(:right) - expect(treegrid).to have_selector(:row, "Alpha parent", aria: { level: 1, expanded: true }, focused: true) + expect(wp_table.treegrid).to have_selector(:row, "Alpha parent", aria: { level: 1, expanded: true }, focused: true) expect_exposed_at_level(descendants.first, 2) expect_exposed_at_level(descendants.last, 3) end @@ -280,8 +327,9 @@ field.input_element.set("Renamed task") wp_table.press_keys(:enter) - expect(treegrid).to have_selector(:row, "No work packages to display.", aria: { level: 1, selected: nil }) - expect(treegrid).to have_selector(:row, "Create new work package", aria: { level: 1, selected: nil }) + expect(wp_table.treegrid).to have_selector(:row, "No work packages to display.", aria: { level: 1, selected: nil }) + expect(wp_table.treegrid).to have_selector(:row, "Create new work package", aria: { level: 1, selected: nil }) + wp_table.expect_row_positions(rowcount: 3, rows: [1, 2, 3]) expect_focus_on_header_row expect_axe_clean_table @@ -298,16 +346,12 @@ end end - def treegrid - find(:table, role: "treegrid", accessible_name: "Work packages") - end - def expect_axe_clean_table expect(page).to be_axe_clean.within("table.work-package-table") end def expect_focus_on_header_row - expect(treegrid).to have_selector(:row, "Subject", aria: { level: 1, selected: nil }, focused: true) + expect(wp_table.treegrid).to have_selector(:row, "Subject", aria: { level: 1, selected: nil }, focused: true) end def accessibility_node(row) diff --git a/spec/support/components/table_pagination.rb b/spec/support/components/table_pagination.rb index 5d822833997c..835c8b6573cc 100644 --- a/spec/support/components/table_pagination.rb +++ b/spec/support/components/table_pagination.rb @@ -40,6 +40,18 @@ def expect_range(from, to, total) end end + def go_to_page(number) + within_pagination do + within(".op-pagination--pages") { click_on number.to_s } + end + end + + def select_per_page(number) + within_pagination do + within(".op-pagination--options") { click_on number.to_s } + end + end + def expect_no_per_page_options within_pagination do expect(page) diff --git a/spec/support/pages/work_packages/work_packages_table.rb b/spec/support/pages/work_packages/work_packages_table.rb index 6170106ce9b1..24d7dba17584 100644 --- a/spec/support/pages/work_packages/work_packages_table.rb +++ b/spec/support/pages/work_packages/work_packages_table.rb @@ -371,6 +371,27 @@ def expect_focus_outside_table expect(page).to have_css(":focus") end + # @return [Capybara::Node::Element] The work package table exposed as a treegrid. + def treegrid + find(:table, role: "treegrid", accessible_name: "Work packages") + end + + # Expects the table's `aria-rowcount` and the `aria-rowindex` of every + # visible row, in document order from the header row down. + # + # @param rowcount [Integer] The expected `aria-rowcount` of the table. + # @param rows [Array] The expected `aria-rowindex` of each exposed row. + def expect_row_positions(rowcount:, rows:) + wait_for { treegrid.all(:row).pluck("aria-rowindex") }.to eq(rows.map(&:to_s)) + expect(treegrid).to match_selector(:table, aria: { rowcount: }) + end + + # Expects the table and all of its rows, hidden ones included, to carry no row position. + def expect_no_row_positions + expect(treegrid).to match_selector(:table, aria: { rowcount: nil }) + expect(treegrid).to have_no_selector(:element, "tr", "aria-rowindex": /./, visible: :all) + end + # Returns the row element for the specified work package. # # @param work_package [WorkPackage] The work package object.