From 6a1959af1ddca97cd83d9fa21a88f9eb2f643312 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 24 Oct 2026 00:48:38 +0100 Subject: [PATCH 01/13] [DREAM-885] Support baseline and i18n in harness Adds a `baseline` harness option that activates the baseline view service stub, and a per-fixture `baseline` field that builds the timestamp attributes `getBaselineState` reads. Adds `translations` with `%{name}` interpolation, leaving the default translator returning keys, and a `formattedId` fixture field defaulting to the hash-prefixed id. https://community.openproject.org/wp/DREAM-885 --- .../testing/table-harness.spec.ts | 41 +++++++++++++++++++ .../wp-fast-table/testing/table-harness.ts | 22 +++++++++- .../testing/work-package-fixture.ts | 14 +++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.spec.ts index 855555b84ff2..fb845ec97239 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/testing/table-harness.spec.ts @@ -26,7 +26,12 @@ //++ import { WorkPackageTableConfiguration } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; +import { WorkPackageViewBaselineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-baseline.service'; +import { getBaselineState } from 'core-app/features/work-packages/components/wp-baseline/baseline-helpers'; import { buildTable, TableHarness } from './table-harness'; +import { buildWorkPackage } from './work-package-fixture'; describe('table harness production markup', () => { let harness:TableHarness|undefined; @@ -51,4 +56,40 @@ describe('table harness production markup', () => { expect(new WorkPackageTableConfiguration({}).keyboardNavigationEnabled).toBe(false); expect(new WorkPackageTableConfiguration({ keyboardNavigationEnabled: true }).keyboardNavigationEnabled).toBe(true); }); + + it('keeps the baseline inactive and the translator returning keys by default', () => { + harness = buildTable({ workPackages: [{ id: '1' }] }); + + expect(harness.injector.get(WorkPackageViewBaselineService).isActive()).toBe(false); + expect(harness.injector.get(I18nService).t('js.x', { name: 'A' })).toBe('js.x'); + }); + + it.each(['added', 'removed', 'updated'] as const)('derives the %s baseline state from the fixture', (baseline) => { + harness = buildTable({ workPackages: [{ id: '1', baseline }], baseline: true }); + const schemaService = harness.injector.get(SchemaCacheService); + + expect(harness.injector.get(WorkPackageViewBaselineService).isActive()).toBe(true); + expect(getBaselineState(buildWorkPackage({ id: '1', baseline }), schemaService)).toBe(baseline); + expect(getBaselineState(buildWorkPackage({ id: '2' }), schemaService)).toBeNull(); + }); + + it('keeps caller-supplied timestamp attributes when the fixture sets no baseline', () => { + const attributesByTimestamp = [{ subject: 'Before' }, {}]; + + expect(buildWorkPackage({ id: '1', attributes: { attributesByTimestamp } }).attributesByTimestamp) + .toBe(attributesByTimestamp); + }); + + it('interpolates configured translations', () => { + harness = buildTable({ workPackages: [{ id: '1' }], translations: { 'js.x': 'Hello %{name}' } }); + const i18n = harness.injector.get(I18nService); + + expect(i18n.t('js.x', { name: 'A' })).toBe('Hello A'); + expect(i18n.t('js.other')).toBe('js.other'); + }); + + it('defaults the formatted id to the hash-prefixed id', () => { + expect(buildWorkPackage({ id: '1' }).formattedId).toBe('#1'); + expect(buildWorkPackage({ id: '1', formattedId: 'PROJ-1' }).formattedId).toBe('PROJ-1'); + }); }); 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 2574e1abb8dc..4d6824880e0a 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 @@ -156,6 +156,10 @@ export interface TableHarnessOptions { timelineVisible?:boolean; requireAll?:(ids:string[]) => Promise; loadPositions?:() => Promise; + /** Activates the baseline view service, so rows read their fixtures' `baseline` state. */ + baseline?:boolean; + /** Whole translated strings by key, with `%{name}` interpolation; unlisted keys translate to themselves. */ + translations?:Record; /** Runs against the fresh injector before the handler registry attaches, to seed state a predecessor view left behind; resolving a view service here constructs it ahead of the transformers, as production does. */ beforeAttach?:(injector:Injector) => void; } @@ -516,11 +520,12 @@ export function harnessProviders(dragService:FakeDragAndDropService, options:Tab of: () => ({ ofProperty: (attribute:string) => fieldSchema(attribute, editable), mappedName: (attribute:string) => attribute, + availableAttributes: ['subject'], isAttributeEditable: (attribute:string) => editable && editableFields.includes(attribute), }), }, }, - { provide: I18nService, useValue: { t: (key:string) => key, locale: 'en' } }, + { provide: I18nService, useValue: { t: translate(options.translations), locale: 'en' } }, { provide: HalResourceService, useValue: {} }, { provide: HalResourceEditingService, @@ -557,7 +562,7 @@ export function harnessProviders(dragService:FakeDragAndDropService, options:Tab { provide: UrlParamsService, useValue: { currentDetailsRouteParams: () => null, basePathWithoutDetails: () => '' } }, { provide: KeepTabService, useValue: { currentDetailsTab: 'overview', currentShowTab: 'activity' } }, { provide: FocusHelperService, useValue: { focus: () => undefined } }, - { provide: WorkPackageViewBaselineService, useValue: { isActive: () => false, isChanged: () => false } }, + { provide: WorkPackageViewBaselineService, useValue: baselineStub(!!options.baseline) }, { provide: HalResourceNotificationService, useValue: { handleRawError: () => undefined, showEditingBlockedError: () => undefined, showSave: () => undefined } }, { provide: WorkPackageNotificationService, useExisting: HalResourceNotificationService }, { provide: EditingPortalService, useValue: new FakeEditingPortalService() }, @@ -577,6 +582,19 @@ export function harnessProviders(dragService:FakeDragAndDropService, options:Tab ]; } +function translate(translations:Record = {}) { + return (key:string, args:Record = {}) => (translations[key] ?? key) + .replace(/%\{(\w+)\}/g, (match, name:string) => (name in args ? String(args[name]) : match)); +} + +function baselineStub(active:boolean) { + return { + isActive: () => active, + isChanged: (workPackage:WorkPackageResource, attribute:string) => active + && Object.prototype.hasOwnProperty.call(workPackage.attributesByTimestamp?.[0] ?? {}, attribute), + }; +} + function relationsStub(relations:RelationFixture[]) { const byWorkPackage = buildRelations(relations); return { diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/testing/work-package-fixture.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/testing/work-package-fixture.ts index f29c27f8a5e6..de1cb95599ac 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/testing/work-package-fixture.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/testing/work-package-fixture.ts @@ -34,6 +34,8 @@ import { groupIdentifier } from '../builders/modes/grouped/grouped-rows-helpers' export interface WorkPackageFixture { id:string; subject?:string; + /** The identifier the row shows and relation buttons name; defaults to `#`. */ + formattedId?:string; /** Further resource attributes, e.g. a linked `status` the table groups by. */ attributes?:Record; ancestors?:WorkPackageFixture[]; @@ -41,6 +43,8 @@ export interface WorkPackageFixture { type?:string; /** Child work packages a `children: true` column expands into child relation rows. */ children?:WorkPackageFixture[]; + /** The change a baseline comparison reports for this work package; read by `getBaselineState`. */ + baseline?:'added'|'removed'|'updated'; } /** A relation `from` → `to` of `type`; seen from `to` it reads as `reverseType` (default `type`). */ @@ -59,6 +63,14 @@ export interface GroupFixture { sums?:GroupObject['sums']; } +function baselineTimestamps(baseline:NonNullable) { + const meta = (present:boolean) => ({ exists: present, matchesFilters: present }); + const base = { $links: {}, _meta: meta(baseline !== 'added'), ...(baseline === 'updated' ? { subject: 'Before' } : {}) }; + const compare = { $links: {}, _meta: meta(baseline !== 'removed') }; + + return [base, compare]; +} + export function buildWorkPackage(fixture:WorkPackageFixture):WorkPackageResource { const subject = fixture.subject ?? `Work package ${fixture.id}`; const href = `/api/v3/work_packages/${fixture.id}`; @@ -68,6 +80,8 @@ export function buildWorkPackage(fixture:WorkPackageFixture):WorkPackageResource children: fixture.children?.map(buildWorkPackage), ...fixture.attributes, id: fixture.id, + formattedId: fixture.formattedId ?? `#${fixture.id}`, + ...(fixture.baseline ? { attributesByTimestamp: baselineTimestamps(fixture.baseline) } : {}), subject, href, $href: href, From c17a525e1a2cb0ca519fb7fe837269b26dbee1bc Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 24 Oct 2026 00:53:15 +0100 Subject: [PATCH 02/13] [DREAM-885] Record row levels in the ledger Adds a required semantic `level` to every rendered occurrence: plain rows and the placeholder are level 1, grouped members and sums rows are 2 beneath their level 1 group header, hierarchy rows take their ancestor count plus one, and relation rows sit one level below their owner. Moves `rowKindOf` and `expandedOf` from the keyboard folder into `helpers/row-expansion.ts` without behavior change, so later tasks can project `aria-level` and `aria-expanded` from the same sources. https://community.openproject.org/wp/DREAM-885 --- .../modes/grouped/grouped-render-pass.spec.ts | 22 +++++++ .../modes/grouped/grouped-render-pass.ts | 4 +- .../hierarchy/hierarchy-collapse.spec.ts | 30 ++++++++++ .../modes/hierarchy/hierarchy-render-pass.ts | 1 + .../builders/primary-render-pass.ts | 5 ++ .../relations/relations-render-pass.spec.ts | 30 ++++++++++ .../relations/relations-render-pass.ts | 1 + .../keyboard-navigation-transformer.spec.ts | 21 +++++++ .../keyboard-navigation-transformer.ts | 3 +- .../handlers/state/keyboard/navigable-rows.ts | 27 +-------- .../wp-fast-table/helpers/row-expansion.ts | 57 +++++++++++++++++++ .../rendered-occurrence-ledger.spec.ts | 13 +++++ .../rendered-occurrence-ledger.ts | 1 + 13 files changed, 186 insertions(+), 29 deletions(-) create mode 100644 frontend/src/app/features/work-packages/components/wp-fast-table/helpers/row-expansion.ts diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.spec.ts index 77d2f749784b..d78fc9571098 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.spec.ts @@ -41,6 +41,7 @@ import { import { GroupHeaderBuilder, } from 'core-app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-header-builder'; +import { buildTable, TableHarness } from 'core-app/features/work-packages/components/wp-fast-table/testing/table-harness'; import { RenderedOccurrenceLedger } from 'core-app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger'; class TestGroupedRenderPass extends GroupedRenderPass { @@ -116,6 +117,27 @@ describe('GroupedRenderPass', () => { }); }); +describe('GroupedRenderPass levels', () => { + let harness:TableHarness; + + afterEach(() => harness.destroy()); + + it('assigns semantic levels to every occurrence', async () => { + const status = { href: '/api/v3/statuses/1' }; + harness = buildTable({ + workPackages: [{ id: '1', attributes: { status } }], + groups: [{ value: 'New', href: status.href, count: 1, sums: { storyPoints: 13 } }], + }); + + await harness.render(); + + const { ledger } = harness.table; + expect(ledger.byKey('group:0:header')!.level).toBe(1); + expect(ledger.byKey('wp:1')!.level).toBe(2); + expect(ledger.byKey('group:0:sums')!.level).toBe(2); + }); +}); + function buildRenderPass(rows:WorkPackageTableRow[], groups:GroupObject[]):TestGroupedRenderPass { const pass = new TestGroupedRenderPass( buildInjector(), diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts index ec7ce110ba8d..49b8edd87d8d 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts @@ -168,7 +168,7 @@ export class GroupedRenderPass extends PlainRenderPass { const [tr, _] = this.rowBuilder.buildEmpty(row.object); tr.classList.add(...additionalClasses); - this.appendRow(row.object, tr, additionalClasses, hidden); + this.appendRow(row.object, tr, additionalClasses, hidden, 2); } /** @@ -184,7 +184,7 @@ export class GroupedRenderPass extends PlainRenderPass { const { additionalClasses, hidden } = this.groupRowState(group); rowElement.classList.add(...additionalClasses); - this.appendNonWorkPackageRow(groupOccurrenceKey(group.index, 'sums'), rowElement, groupClass, additionalClasses, hidden, group.identifier); + this.appendNonWorkPackageRow(groupOccurrenceKey(group.index, 'sums'), rowElement, groupClass, additionalClasses, hidden, group.identifier, 2); } private groupRowState(group:GroupObject):{ additionalClasses:string[], hidden:boolean } { diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts index 2ca975e66336..2b384475df09 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts @@ -268,3 +268,33 @@ describe('Hierarchy table collapse', () => { }); }); }); + +describe('Hierarchy table levels', () => { + let harness:TableHarness; + + afterEach(() => harness.destroy()); + + it('assigns semantic levels to every occurrence', async () => { + const outerAncestor = { id: '9' }; + const innerAncestor = { id: '10', ancestors: [outerAncestor] }; + harness = buildTable({ + workPackages: [ + parent, + child, + { id: '3', ancestors: [parent, child] }, + { id: '5', ancestors: [outerAncestor, innerAncestor] }, + ], + showHierarchies: true, + }); + + await harness.render(); + + const levelOf = (key:string) => harness.table.ledger.byKey(key)!.level; + expect(levelOf('wp:1')).toBe(1); + expect(levelOf('wp:2')).toBe(2); + expect(levelOf('wp:3')).toBe(3); + expect(levelOf('ancestor:9')).toBe(1); + expect(levelOf('ancestor:10')).toBe(2); + expect(levelOf('wp:5')).toBe(3); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-render-pass.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-render-pass.ts index bf464ec4f36d..c80544472981 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-render-pass.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-render-pass.ts @@ -296,6 +296,7 @@ export class HierarchyRenderPass extends PrimaryRenderPass { workPackageId: workPackage.id!, renderType: 'primary', hidden, + level: workPackage.getAncestors().length + 1, }; } } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts index 01c9d34e3509..2c85dd202c5b 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts @@ -205,6 +205,7 @@ public readonly injector:Injector, workPackageId: null, renderType: 'primary', hidden: false, + level: 1, }); } } @@ -221,6 +222,7 @@ workPackage:WorkPackageResource, row:HTMLTableRowElement, additionalClasses:string[] = [], hidden = false, + level = 1, ) { this.registerAppended(row, { key: wpOccurrenceKey(workPackage.id!), @@ -230,6 +232,7 @@ workPackage:WorkPackageResource, workPackageId: workPackage.id!, renderType: 'primary', hidden, + level, }); } @@ -246,6 +249,7 @@ workPackage:WorkPackageResource, additionalClasses:string[] = [], hidden = false, groupIdentifier?:string, + level = 1, ) { row.classList.add(classIdentifier); this.registerAppended(row, { @@ -256,6 +260,7 @@ workPackage:WorkPackageResource, workPackageId: null, renderType: 'primary', hidden, + level, groupIdentifier, }); } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.spec.ts index 47b135eafe09..002a25829dde 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.spec.ts @@ -63,4 +63,34 @@ describe('RelationsRenderPass', () => { expect(childRow.dataset.workPackageId).toBe('3'); expect(childRow.dataset.occurrenceKey).toBe('relation:children:1:3'); }); + + it('assigns semantic levels to every occurrence', async () => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }], + relations: [{ from: '1', to: '2', type: 'follows', reverseType: 'precedes' }], + columns: ['id', 'subject', { id: 'relationsOfTypeFollows', relationType: 'follows' }], + }); + harness.expand('1', 'relationsOfTypeFollows'); + + await harness.render(); + + expect(harness.table.ledger.byKey('wp:1')!.level).toBe(1); + expect(harness.table.ledger.byKey('relation:ofType:1:2')!.level).toBe(2); + }); + + it('places a relation row one level below its owner in a hierarchy', async () => { + const parent = { id: '1' }; + harness = buildTable({ + workPackages: [parent, { id: '2', ancestors: [parent] }, { id: '3' }], + relations: [{ from: '2', to: '3', type: 'follows', reverseType: 'precedes' }], + columns: ['id', 'subject', { id: 'relationsOfTypeFollows', relationType: 'follows' }], + showHierarchies: true, + }); + harness.expand('2', 'relationsOfTypeFollows'); + + await harness.render(); + + expect(harness.table.ledger.byKey('wp:2')!.level).toBe(2); + expect(harness.table.ledger.byKey('relation:ofType:2:3')!.level).toBe(3); + }); }); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.ts index 62328c9e1bc5..0eda6c568b84 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.ts @@ -144,6 +144,7 @@ export class RelationsRenderPass { workPackageId: to.id!, renderType: this.renderType, hidden: row.hidden, + level: row.level + 1, relation, }, ); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.spec.ts index 53eb647a39eb..50299b5f05eb 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.spec.ts @@ -41,6 +41,7 @@ import { buildGroup } from '../../../testing/work-package-fixture'; import { wpOccurrenceKey } from '../../../rendered-occurrence-ledger'; import { KeyboardNavigation } from './keyboard-navigation-transformer'; import { headerRowKey, totalsRowKey } from './navigable-rows'; +import { expandedOf } from '../../../helpers/row-expansion'; import { insideGrid, navigableCells } from './roving-tabindex'; class AnchorDisplayField extends TextDisplayField { @@ -233,6 +234,26 @@ describe('KeyboardNavigationTransformer', () => { expect(table.row('1')).toHaveFocus(); }); + it('reports no expansion for a hierarchy parent without a rendered indicator', async () => { + const table = await mount({ + workPackages: [{ id: '1', children: [{ id: '2' }] }, { id: '2', ancestors: [{ id: '1' }] }], + columns: ['id'], + showHierarchies: true, + }); + const hierarchies = table.injector.get(WorkPackageViewHierarchiesService); + const occurrence = table.table.ledger.byKey(wpOccurrenceKey('1'))!; + hierarchies.collapse('1'); + table.row('1').focus(); + + expect(expandedOf(table.table, hierarchies, occurrence, 'workPackage')).toBeNull(); + expect(pressActive('ArrowRight')).toBe(false); + expect(hierarchies.collapsed('1')).toBe(true); + expect(cell(table.row('1'), 0)).toHaveFocus(); + expect(pressActive('ArrowLeft')).toBe(false); + expect(table.row('1')).toHaveFocus(); + expect(hierarchies.collapsed('1')).toBe(true); + }); + it('ArrowLeft on an expanded group header collapses it', async () => { const table = await mount(grouped); const collapsedGroups = table.injector.get(WorkPackageViewCollapsedGroupsService); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.ts index 8014eac7d2d9..bc5f77964e19 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/keyboard-navigation-transformer.ts @@ -41,11 +41,12 @@ import { wpOccurrenceKey } from '../../../rendered-occurrence-ledger'; import type { TableEventComponent } from '../../table-handler-registry'; import { activateTableField } from '../../cell/edit-cell-handler'; import { activateRow } from '../../row/click-handler'; +import { rowKindOf } from '../../../helpers/row-expansion'; import { FocusPosition, initialPosition, NavigableRow, NavKey, nearestPosition, nextPosition, reconcilePosition, } from './focus-position'; import { - buildNavigableRows, headerRowKey, headerRowOf, rowKindOf, totalsRowKey, totalsRowOf, + buildNavigableRows, headerRowKey, headerRowOf, totalsRowKey, totalsRowOf, } from './navigable-rows'; import { insideGrid, navigableCellClassName, navigableCells, paintRovingTabindex } from './roving-tabindex'; 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 53270340a8fe..c23d8c3b5e95 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 @@ -30,6 +30,7 @@ import type { RenderedOccurrence } from '../../../rendered-occurrence-ledger'; 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'; export const headerRowKey = 'header'; export const totalsRowKey = 'totals'; @@ -45,32 +46,6 @@ export function totalsRowOf(table:WorkPackageTable):HTMLTableRowElement|null { return Array.from(rows).find((row) => !table.tbody.contains(row)) ?? null; } -/** The navigable kind of a rendered occurrence, or null for rows navigation skips. */ -export function rowKindOf(occurrence:RenderedOccurrence):RowKind|null { - if (occurrence.key.startsWith('group:')) { - return occurrence.key.endsWith(':sums') ? 'sums' : 'groupHeader'; - } - - return occurrence.workPackageId === null ? null : 'workPackage'; -} - -function expandedOf( - table:WorkPackageTable, - hierarchies:WorkPackageViewHierarchiesService, - occurrence:RenderedOccurrence, - kind:RowKind, -):boolean|null { - if (kind === 'groupHeader') { - return !table.workPackageViewCollapsedGroupsService.config.state[occurrence.groupIdentifier ?? '']; - } - - if (kind === 'workPackage' && occurrence.element?.querySelector('.wp-table--hierarchy-indicator')) { - return !hierarchies.collapsed(occurrence.workPackageId!); - } - - return null; -} - function occurrenceRow( table:WorkPackageTable, hierarchies:WorkPackageViewHierarchiesService, diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/row-expansion.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/row-expansion.ts new file mode 100644 index 000000000000..e930b38cf681 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/row-expansion.ts @@ -0,0 +1,57 @@ +//-- 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 { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service'; +import type { RenderedOccurrence } from '../rendered-occurrence-ledger'; +import type { WorkPackageTable } from '../wp-fast-table'; +import type { RowKind } from '../handlers/state/keyboard/focus-position'; + +/** The navigable kind of a rendered occurrence, or null for rows navigation skips. */ +export function rowKindOf(occurrence:RenderedOccurrence):RowKind|null { + if (occurrence.key.startsWith('group:')) { + return occurrence.key.endsWith(':sums') ? 'sums' : 'groupHeader'; + } + + return occurrence.workPackageId === null ? null : 'workPackage'; +} + +export function expandedOf( + table:WorkPackageTable, + hierarchies:WorkPackageViewHierarchiesService, + occurrence:RenderedOccurrence, + kind:RowKind, +):boolean|null { + if (kind === 'groupHeader') { + return !table.workPackageViewCollapsedGroupsService.config.state[occurrence.groupIdentifier ?? '']; + } + + if (kind === 'workPackage' && occurrence.element?.querySelector('.wp-table--hierarchy-indicator')) { + return !hierarchies.collapsed(occurrence.workPackageId!); + } + + return null; +} diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.spec.ts index 9e57a5031b73..24781208c654 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.spec.ts @@ -44,6 +44,7 @@ function draftOccurrence(key:OccurrenceKey, overrides:Partial = workPackageId: '1', renderType: 'primary', hidden: false, + level: 1, element: null, additionalClasses: [], workPackage: null, @@ -230,6 +231,18 @@ describe('RenderedOccurrenceLedger', () => { expect(ledger.generation).toBe(generation); }); + it('keeps the level through commit, setHidden and replaceElement', () => { + const ledger = new RenderedOccurrenceLedger(); + commitKeys(ledger, draftOccurrence('a', { level: 3, element: document.createElement('tr') })); + expect(ledger.byKey('a')?.level).toBe(3); + + ledger.setHidden(new Map([['a', true]])); + expect(ledger.byKey('a')?.level).toBe(3); + + ledger.replaceElement('a', document.createElement('tr')); + expect(ledger.byKey('a')?.level).toBe(3); + }); + it('looks up occurrences by element', () => { const ledger = new RenderedOccurrenceLedger(); const el = document.createElement('tr'); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts index f49084526539..a3c57e1245ea 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts @@ -39,6 +39,7 @@ export interface RenderedOccurrence { readonly workPackageId:string|null; readonly renderType:'primary'|'relations'|'child_relations'; readonly hidden:boolean; + readonly level:number; /** Stable identifier of the group for `group:*:header` and `group:*:sums` occurrences. */ readonly groupIdentifier?:string; element:HTMLTableRowElement|null; From 0090b73072b2b67abf81177e27f50a06cb55178d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 24 Oct 2026 01:00:00 +0100 Subject: [PATCH 03/13] [DREAM-885] Align column spans on enabled tables Adds renderedColumns, the single calculation of the physical column set (query columns, drag, baseline and one trailing actions or configuration column). Keyboard-enabled tables derive their column count, colgroup and augmented row columns from it, so header, rows, sums, totals and full-width rows span the same columns. This fixes the missing drag column when drag and drop is enabled outside manual sort mode, and the phantom trailing column when neither actions nor the column menu are enabled. The inline-create row still builds its Cancel cell in that case, one cell beyond the span. Capability-off tables keep their current column arithmetic. https://community.openproject.org/wp/DREAM-885 --- .../modes/grouped/grouped-sums-row.spec.ts | 48 ++++++++++ .../builders/rendered-columns.ts | 47 ++++++++++ .../builders/rows/single-row-builder.spec.ts | 47 ++++++++++ .../builders/rows/single-row-builder.ts | 21 ++++- .../inline-create-row-builder.ts | 6 ++ .../wp-table/wp-table.component.html | 2 +- .../wp-table/wp-table.component.spec.ts | 87 ++++++++++++++++++- .../components/wp-table/wp-table.component.ts | 27 ++++-- 8 files changed, 271 insertions(+), 14 deletions(-) create mode 100644 frontend/src/app/features/work-packages/components/wp-fast-table/builders/rendered-columns.ts diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts index dcbc13472ba1..bd7e8599011f 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts @@ -28,6 +28,7 @@ import { fireEvent, waitFor, within } from '@testing-library/dom'; import { groupOccurrenceKey } from '../../../rendered-occurrence-ledger'; import { buildTable, TableHarness } from '../../../testing/table-harness'; +import { GroupSumsBuilder } from './group-sums-builder'; const newStatus = { href: '/api/v3/statuses/1' }; const inProgressStatus = { href: '/api/v3/statuses/2' }; @@ -129,3 +130,50 @@ describe('Grouped table sums rows', () => { expect(harness.table.ledger.byKey(groupOccurrenceKey(1, 'sums'))?.groupIdentifier).toBe(identifier); }); }); + +describe('Grouped table sums physical columns', () => { + let harness:TableHarness; + + afterEach(() => harness.destroy()); + + const mount = async (configuration:Record, baseline:boolean) => { + harness = buildTable({ + workPackages: [ + { id: '1', attributes: { status: newStatus } }, + { id: '2', attributes: { status: newStatus } }, + ], + groups: [{ value: 'New', href: newStatus.href, count: 2, sums: { storyPoints: 13 } }], + columns: ['id', 'subject'], + configuration, + baseline, + }); + await harness.render(); + }; + + const spannedColumns = (row:HTMLTableRowElement) => Array.from(row.cells) + .reduce((total, cell) => total + cell.colSpan, 0); + + describe.each([true, false])('on an enabled table with drag and drop %s', (drag) => { + describe.each([true, false])('and baseline %s', (baseline) => { + it.each([true, false])('spans the same columns in sums and totals rows with a trailing column %s', async (trailing) => { + await mount({ + keyboardNavigationEnabled: true, + dragAndDropEnabled: drag, + columnMenuEnabled: trailing, + }, baseline); + const expected = 2 + Number(drag) + Number(baseline) + Number(trailing); + const totals = document.createElement('tr'); + new GroupSumsBuilder(harness.injector, harness.table).renderColumns({ storyPoints: 34 }, totals); + + expect(spannedColumns(harness.row('2').nextElementSibling as HTMLTableRowElement)).toBe(expected); + expect(spannedColumns(totals)).toBe(expected); + }); + }); + }); + + it('keeps the trailing sums cell of a capability-off table', async () => { + await mount({ dragAndDropEnabled: true, columnMenuEnabled: false, actionsColumnEnabled: false }, true); + + expect((harness.row('2').nextElementSibling as HTMLTableRowElement).cells).toHaveLength(2 + 1 + 1 + 1); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rendered-columns.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rendered-columns.ts new file mode 100644 index 000000000000..b1393887bd15 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rendered-columns.ts @@ -0,0 +1,47 @@ +//-- 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 { WorkPackageTableConfiguration } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; + +export interface RenderedColumns { + drag:boolean; + baseline:boolean; + trailing:boolean; + count(queryColumns:number):number; +} + +export function renderedColumns(configuration:WorkPackageTableConfiguration, baselineActive:boolean):RenderedColumns { + const drag = configuration.dragAndDropEnabled; + const trailing = configuration.actionsColumnEnabled || configuration.columnMenuEnabled; + + return { + drag, + baseline: baselineActive, + trailing, + count: (queryColumns) => queryColumns + Number(drag) + Number(baselineActive) + Number(trailing), + }; +} diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.spec.ts index 63fe3f06cc51..c9c77a5522d5 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.spec.ts @@ -30,6 +30,7 @@ import { HalResourceEditingService } from 'core-app/shared/components/fields/edi import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { nextFrame } from 'core-common/testing/timing'; import { buildTable, TableHarness } from '../../testing/table-harness'; +import { SingleRowBuilder } from './single-row-builder'; describe('SingleRowBuilder with two tables showing the same work package', () => { const harnesses:TableHarness[] = []; @@ -73,3 +74,49 @@ describe('SingleRowBuilder with two tables showing the same work package', () => expect(subjectCell(first)).toBe(otherCell); }); }); + +describe('SingleRowBuilder physical columns', () => { + let harness:TableHarness; + + afterEach(() => harness.destroy()); + + const spannedColumns = (row:HTMLTableRowElement) => Array.from(row.cells) + .reduce((total, cell) => total + cell.colSpan, 0); + + const mount = async (configuration:Record, baseline:boolean) => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }], + relations: [{ from: '1', to: '2', type: 'follows', reverseType: 'precedes' }], + columns: ['id', 'subject', { id: 'relationsOfTypeFollows', relationType: 'follows' }], + configuration, + baseline, + }); + harness.expand('1', 'relationsOfTypeFollows'); + await harness.render(); + }; + + describe.each([true, false])('on an enabled table with drag and drop %s', (drag) => { + describe.each([true, false])('and baseline %s', (baseline) => { + it.each([true, false])('spans the same columns in every row with a trailing column %s', async (trailing) => { + await mount({ + keyboardNavigationEnabled: true, + dragAndDropEnabled: drag, + columnMenuEnabled: trailing, + }, baseline); + const expected = 3 + Number(drag) + Number(baseline) + Number(trailing); + + expect(spannedColumns(harness.row('1'))).toBe(expected); + expect(spannedColumns(harness.row('2'))).toBe(expected); + expect(spannedColumns(harness.relationRow('1', '2'))).toBe(expected); + expect(new SingleRowBuilder(harness.injector, harness.table).placeholderRow.cells[0].colSpan).toBe(expected); + }); + }); + }); + + it('keeps the augmented columns of a capability-off table', async () => { + await mount({ dragAndDropEnabled: true, columnMenuEnabled: false, actionsColumnEnabled: false }, true); + + expect(harness.row('1').cells).toHaveLength(3 + 1 + 1); + expect(new SingleRowBuilder(harness.injector, harness.table).placeholderRow.cells[0].colSpan).toBe(3 + 1 + 1 + 1); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts index 68c15664df9e..4dff17c930a9 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts @@ -56,6 +56,7 @@ import { WorkPackageTable } from 'core-app/features/work-packages/components/wp- import { WorkPackageViewBaselineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-baseline.service'; import { BaselineColumnBuilder } from 'core-app/features/work-packages/components/wp-fast-table/builders/baseline/baseline-column-builder'; import { ShareCellbuilder } from '../share-cell-builder'; +import { renderedColumns } from '../rendered-columns'; // Work package table row entries export const tableRowClassName = 'wp-table--row'; @@ -109,14 +110,28 @@ export class SingleRowBuilder { * Returns the current set of columns, augmented by the internal columns * we add for buttons and timeline. */ - private buildAugmentedColumns():QueryColumn[] { + protected buildAugmentedColumns():QueryColumn[] { + const { configuration } = this.workPackageTable; + const baselineActive = this.wpTableBaseline.isActive(); + + if (keyboardNavigationEnabled(configuration)) { + const rendered = renderedColumns(configuration, baselineActive); + + return [ + ...(rendered.drag ? [internalSortColumn] : []), + ...(rendered.baseline ? [internalBaselineColumn] : []), + ...this.columns, + ...(rendered.trailing ? [internalContextMenuColumn] : []), + ]; + } + const columns = [...this.columns, internalContextMenuColumn]; - if (this.wpTableBaseline.isActive()) { + if (baselineActive) { columns.unshift(internalBaselineColumn); } - if (this.workPackageTable.configuration.dragAndDropEnabled) { + if (configuration.dragAndDropEnabled) { columns.unshift(internalSortColumn); } diff --git a/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts index 779af4ea63ad..34273bbf96da 100644 --- a/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts @@ -68,6 +68,12 @@ export class InlineCreateRowBuilder extends SingleRowBuilder { }; } + protected buildAugmentedColumns():QueryColumn[] { + const columns = super.buildAugmentedColumns(); + + return columns.includes(internalContextMenuColumn) ? columns : [...columns, internalContextMenuColumn]; + } + public buildCell(workPackage:WorkPackageResource, column:QueryColumn):HTMLTableCellElement|null { switch (column.id) { case internalContextMenuColumn.id: diff --git a/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.html b/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.html index ed5b7bd21396..d91279cbf41b 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.html +++ b/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.html @@ -20,7 +20,7 @@ @if (configuration.columnMenuEnabled) { } - @if (!configuration.columnMenuEnabled) { + @if (!configuration.columnMenuEnabled && trailingColumnRendered) { } 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 9f17e46dfeea..075e6dc86194 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 @@ -57,7 +57,7 @@ import { WorkPackagesTableComponent } from './wp-table.component'; class ListViewStandInComponent { showTable = signal(true); - configuration = { + configuration:Record = { actionsColumnEnabled: false, columnMenuEnabled: false, contextMenuEnabled: false, @@ -293,3 +293,88 @@ describe('WorkPackagesTableComponent lifecycle', () => { }); }); + +describe('WorkPackagesTableComponent physical columns', () => { + const queryColumns = 2; + let mounted:HTMLElement|undefined; + + afterEach(() => { + mounted?.remove(); + mounted = undefined; + }); + + async function mount(configuration:Record, baseline:boolean) { + await TestBed.configureTestingModule({ + declarations: [WorkPackagesTableComponent, ListViewStandInComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + ...harnessProviders(new FakeDragAndDropService(), { workPackages: [] }), + WorkPackageViewSumService, + { provide: WorkPackageViewBaselineService, useValue: { isActive: () => baseline, isChanged: () => false, live$: () => of(null) } }, + ], + }).compileComponents(); + + const injector = TestBed.inject(Injector); + injector.get(DisplayFieldService).addFieldType(TextDisplayField, 'text', ['String']); + const querySpace = TestBed.inject(IsolatedQuerySpace); + const states = TestBed.inject(States); + const query = buildQuery(['id', 'subject'], null, false, false); + querySpace.query.putValue(query); + querySpace.groups.putValue([]); + initializeViewServices(injector, query); + TestBed.inject(WorkPackageViewSumService).initialize(query); + querySpace.results.putValue({ elements: [], count: 0, total: 0 } as unknown as WorkPackageCollectionResource); + + const fixture = TestBed.createComponent(ListViewStandInComponent); + fixture.componentInstance.configuration = { ...fixture.componentInstance.configuration, ...configuration }; + mounted = fixture.nativeElement as HTMLElement; + document.body.appendChild(mounted); + fixture.detectChanges(); + + const debug = fixture.debugElement.query(By.directive(WorkPackagesTableComponent)); + const element = debug.nativeElement as HTMLElement; + const timelineBody = document.createElement('div'); + element.querySelector('.work-packages-tabletimeline--timeline-side')!.appendChild(timelineBody); + (debug.componentInstance as WorkPackagesTableComponent) + .registerTimeline({} as WorkPackageTimelineTableController, timelineBody); + + const resources = [buildWorkPackage({ id: '1' }), buildWorkPackage({ id: '2' })]; + 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.initialized.putValue(null); + await waitFor(() => expect(element.querySelectorAll('tr.wp-table--row[data-work-package-id]')).toHaveLength(2)); + + return { fixture, element, component: debug.componentInstance as WorkPackagesTableComponent }; + } + + const spannedColumns = (row:HTMLTableRowElement) => Array.from(row.cells) + .reduce((total, cell) => total + cell.colSpan, 0); + + describe.each([true, false])('on an enabled table with drag and drop %s', (drag) => { + describe.each([true, false])('and baseline %s', (baseline) => { + it.each([true, false])('renders the same columns in header, colgroup and rows with a trailing column %s', async (trailing) => { + const { element, component } = await mount({ + keyboardNavigationEnabled: true, + dragAndDropEnabled: drag, + actionsColumnEnabled: trailing, + }, baseline); + const expected = queryColumns + Number(drag) + Number(baseline) + Number(trailing); + + expect(element.querySelectorAll('thead th')).toHaveLength(expected); + expect(element.querySelectorAll('colgroup col')).toHaveLength(expected); + element.querySelectorAll('tr.wp-table--row[data-work-package-id]') + .forEach((row) => expect(spannedColumns(row)).toBe(expected)); + expect(component.numTableColumns).toBe(expected); + expect(component.workPackageTable.colspan).toBe(expected); + }); + }); + }); + + it('keeps the column arithmetic of a capability-off table', async () => { + const { element, component } = await mount({ dragAndDropEnabled: true }, true); + + expect(element.querySelectorAll('thead th')).toHaveLength(queryColumns + 2); + expect(element.querySelectorAll('colgroup col')).toHaveLength(queryColumns + 2 + 1); + expect(component.numTableColumns).toBe(queryColumns + 2 + 1); + }); +}); 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 90d95a2ab95b..da1e9931630d 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 @@ -52,6 +52,7 @@ import { } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; import { onDestroySafely } from 'core-app/shared/helpers/angular/owned-ui-cleanup'; import { States } from 'core-app/core/states/states.service'; +import { renderedColumns } from 'core-app/features/work-packages/components/wp-fast-table/builders/rendered-columns'; import { QueryGroupByResource } from 'core-app/features/hal/resources/query-group-by-resource'; import { WorkPackageViewBaselineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-baseline.service'; @@ -133,6 +134,8 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O public numTableColumns:number; + public trailingColumnRendered = true; + public timelineVisible:boolean; public manualSortEnabled:boolean; @@ -197,15 +200,21 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O this.baselineEnabled = this.wpTableBaseline.isActive(); this.limitedResults = this.manualSortEnabled && results.total > results.count; - // Total columns = all available columns + id + context menu - this.numTableColumns = this.columns.length + 2; - - if (this.manualSortEnabled) { - this.numTableColumns += 1; - } - - if (this.baselineEnabled) { - this.numTableColumns += 1; + if (this.configuration.keyboardNavigationEnabled) { + const rendered = renderedColumns(this.configuration, this.baselineEnabled); + this.trailingColumnRendered = rendered.trailing; + this.numTableColumns = rendered.count(this.columns.length); + } else { + // Total columns = all available columns + id + context menu + this.numTableColumns = this.columns.length + 2; + + if (this.manualSortEnabled) { + this.numTableColumns += 1; + } + + if (this.baselineEnabled) { + this.numTableColumns += 1; + } } if (this.workPackageTable) { From 2cd4355aa15534bd48e855be4e62fb9cb1f1b12b Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 24 Oct 2026 01:11:42 +0100 Subject: [PATCH 04/13] [DREAM-885] Project treegrid state onto table rows Adds TreegridSemantics, a small idempotent writer that keyboard-enabled tables construct. It projects the committed ledger onto the results body: row and gridcell roles on each row's own cells, aria-level, aria-selected on work package occurrences, aria-expanded from the shared expansion resolver, aria-hidden for hidden occurrences and the drag placeholder, and rowgroup exposure of the results section. publishRendered paints before publication, which covers full renders, setHidden and the empty visibility map of setGroupsCollapseState. refreshRows repaints every occurrence of the refreshed work package before notifying, and SelectionTransformer repaints selection after its CSS painting. Exports paintRowRole and paintSectionExposure for the template-owned and asynchronous producers. Capability-off tables keep a null writer and their previous markup. On enabled tables, setHidden rebuilds a pending render, so a group or hierarchy collapse that lands before the frame commit cannot restore the draft's stale visibility beside the live aria-expanded value. Capability-off tables keep the existing race. https://community.openproject.org/wp/DREAM-885 --- .../modes/grouped/grouped-collapse.spec.ts | 33 ++ .../hierarchy/hierarchy-collapse.spec.ts | 30 ++ .../handlers/state/selection-transformer.ts | 1 + .../rendered-occurrence-ledger.ts | 4 + .../semantics/treegrid-semantics.spec.ts | 440 ++++++++++++++++++ .../semantics/treegrid-semantics.ts | 108 +++++ .../components/wp-fast-table/wp-fast-table.ts | 12 + .../view-services/rendered-occurrences.ts | 6 +- 8 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.spec.ts create mode 100644 frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.ts diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-collapse.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-collapse.spec.ts index 0a01674f02ff..4807a9746089 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-collapse.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-collapse.spec.ts @@ -140,3 +140,36 @@ describe('Grouped table collapse', () => { expect(harness.table.ledger.keyAt(0)).toBe(firstKey); }); }); + +describe('Grouped table collapse on a keyboard-enabled table', () => { + let harness:TableHarness; + + afterEach(() => harness.destroy()); + + it('projects the collapse onto row expansion and hidden state', async () => { + harness = buildTable({ + workPackages: [ + { id: '1', attributes: { status: newStatus } }, + { id: '2', attributes: { status: inProgressStatus } }, + ], + groups: [ + { value: 'New', href: newStatus.href, count: 1 }, + { value: 'In progress', href: inProgressStatus.href, count: 1 }, + ], + configuration: { keyboardNavigationEnabled: true }, + }); + await harness.render(); + expect(harness.groupHeader(0)).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(harness.groupHeader(0).querySelector('.expander')!); + + await waitFor(() => expect(harness.groupHeader(0)).toHaveAttribute('aria-expanded', 'false')); + expect(harness.row('1')).toHaveAttribute('aria-hidden', 'true'); + expect(harness.row('2')).not.toHaveAttribute('aria-hidden'); + + fireEvent.click(harness.groupHeader(0).querySelector('.expander')!); + + await waitFor(() => expect(harness.groupHeader(0)).toHaveAttribute('aria-expanded', 'true')); + expect(harness.row('1')).not.toHaveAttribute('aria-hidden'); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts index 2b384475df09..46b345b9ed7d 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts @@ -269,6 +269,36 @@ describe('Hierarchy table collapse', () => { }); }); +describe('Hierarchy table collapse on a keyboard-enabled table', () => { + let harness:TableHarness; + + afterEach(() => harness.destroy()); + + it('projects the collapse onto row expansion and hidden state', async () => { + harness = buildTable({ + workPackages: [parent, child, { id: '3', ancestors: [parent, child] }, { id: '4' }], + showHierarchies: true, + configuration: { keyboardNavigationEnabled: true }, + }); + await harness.render(); + expect(harness.row('1')).toHaveAttribute('aria-expanded', 'true'); + expect(harness.row('3')).not.toHaveAttribute('aria-expanded'); + + fireEvent.click(within(harness.row('1')).getByRole('button')); + + await waitFor(() => expect(harness.row('1')).toHaveAttribute('aria-expanded', 'false')); + expect(harness.row('2')).toHaveAttribute('aria-hidden', 'true'); + expect(harness.row('3')).toHaveAttribute('aria-hidden', 'true'); + expect(harness.row('4')).not.toHaveAttribute('aria-hidden'); + + fireEvent.click(within(harness.row('1')).getByRole('button')); + + await waitFor(() => expect(harness.row('1')).toHaveAttribute('aria-expanded', 'true')); + expect(harness.row('2')).not.toHaveAttribute('aria-hidden'); + expect(harness.row('3')).not.toHaveAttribute('aria-hidden'); + }); +}); + describe('Hierarchy table levels', () => { let harness:TableHarness; diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts index ed79ba4c2df9..6f94c165b7b6 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts @@ -113,5 +113,6 @@ export class SelectionTransformer { pressed: this.wpTableFocus.isFocused(workPackageId), }); }); + this.table.semantics?.paintSelection(); } } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts index a3c57e1245ea..530704cf25df 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger.ts @@ -131,6 +131,10 @@ export class RenderedOccurrenceLedger { return this.records.length; } + get occurrences():readonly RenderedOccurrence[] { + return this.records; + } + beginRender(withTimeline = false):RenderDraft { return new Draft(withTimeline); } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.spec.ts new file mode 100644 index 000000000000..de6728ba8540 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.spec.ts @@ -0,0 +1,440 @@ +//-- 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 { waitFor, within } from '@testing-library/dom'; +import { firstValueFrom } from 'rxjs'; +import { map, skip, take } from 'rxjs/operators'; +import { States } from 'core-app/core/states/states.service'; +import { WorkPackageViewCollapsedGroupsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service'; +import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service'; +import { buildTable, TableHarness, TableHarnessOptions } from '../testing/table-harness'; +import { WorkPackageFixture } from '../testing/work-package-fixture'; +import { groupOccurrenceKey, placeholderOccurrenceKey, relationOccurrenceKey, wpOccurrenceKey } from '../rendered-occurrence-ledger'; + +const newStatus = { href: '/api/v3/statuses/1' }; +const inProgressStatus = { href: '/api/v3/statuses/2' }; + +const groupedFixtures:Pick = { + workPackages: [ + { id: '1', attributes: { status: newStatus } }, + { id: '2', attributes: { status: newStatus } }, + { id: '3', attributes: { status: inProgressStatus } }, + ], + groups: [ + { value: 'New', href: newStatus.href, count: 2, sums: { storyPoints: 5 } }, + { value: 'In progress', href: inProgressStatus.href, count: 1, sums: { storyPoints: 8 } }, + ], +}; + +const relationColumn = { id: 'relationsOfTypeFollows', relationType: 'follows' }; +const parent:WorkPackageFixture = { id: '1' }; +const child:WorkPackageFixture = { id: '2', ancestors: [parent] }; + +describe('TreegridSemantics', () => { + let harness:TableHarness; + + const mount = (options:Partial = {}) => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }, { id: '3' }], + ...options, + configuration: { keyboardNavigationEnabled: true, ...options.configuration }, + }); + return harness; + }; + + const element = (key:string) => harness.table.ledger.byKey(key)!.element!; + const groupHeader = (index:number) => element(groupOccurrenceKey(index, 'header')); + const sumsRow = (index:number) => element(groupOccurrenceKey(index, 'sums')); + const groupIdentifier = (index:number) => harness.groupHeader(index).dataset.groupIdentifier!; + const toggleGroup = (index:number) => harness.injector.get(WorkPackageViewCollapsedGroupsService) + .toggleGroupCollapseState(groupIdentifier(index)); + const hierarchies = () => harness.injector.get(WorkPackageViewHierarchiesService); + const resource = (id:string) => harness.injector.get(States).workPackages.get(id).value!; + + const atNextPublication = (read:() => T):Promise => firstValueFrom( + harness.querySpace.tableRendered.values$().pipe(skip(1), take(1), map(read)), + ); + + const atNextRefresh = (read:() => T):Promise => firstValueFrom( + harness.table.rowsRefreshed$.pipe(take(1), map(read)), + ); + + const expectHidden = (row:HTMLElement) => expect(row).toHaveAttribute('aria-hidden', 'true'); + const expectExposed = (row:HTMLElement) => expect(row).not.toHaveAttribute('aria-hidden'); + + afterEach(() => harness.destroy()); + + it('paints rows, levels and cell roles after a full render', async () => { + mount({ + ...groupedFixtures, + baseline: true, + configuration: { dragAndDropEnabled: true }, + }); + await harness.render(); + + const nested = document.createElement('table'); + nested.innerHTML = 'Editor'; + harness.row('1').cells[harness.row('1').cells.length - 1].append(nested); + harness.table.setHidden(new Map([[wpOccurrenceKey('1'), false]])); + + expect(harness.row('1')).toHaveAttribute('role', 'row'); + expect(harness.row('1')).toHaveAttribute('aria-level', '2'); + expect(groupHeader(0)).toHaveAttribute('role', 'row'); + expect(groupHeader(0)).toHaveAttribute('aria-level', '1'); + expect(sumsRow(0)).toHaveAttribute('aria-level', '2'); + + [harness.row('1'), groupHeader(0), sumsRow(0)].forEach((row) => { + Array.from(row.cells).forEach((cell) => expect(cell).toHaveAttribute('role', 'gridcell')); + }); + expect(harness.row('1').querySelector('.__internal-sorthandle')).toHaveAttribute('role', 'gridcell'); + expect(harness.row('1').querySelector('.op-table-baseline--column-cell')).toHaveAttribute('role', 'gridcell'); + expect(groupHeader(0).cells[0]).toHaveAttribute('colspan'); + expect(nested.querySelector('tr')).not.toHaveAttribute('role'); + expect(nested.querySelector('td')).not.toHaveAttribute('role'); + }); + + it('exposes selection only on work package occurrences', async () => { + mount({ + ...groupedFixtures, + relations: [{ from: '1', to: '2', type: 'follows', reverseType: 'precedes' }], + columns: ['id', 'subject', relationColumn], + }); + harness.expand('1', relationColumn.id); + await harness.render(); + const relationRow = harness.relationRow('1', '2'); + + harness.click('2'); + + await waitFor(() => expect(harness.row('2')).toHaveAttribute('aria-selected', 'true')); + expect(relationRow).toHaveAttribute('aria-selected', 'true'); + expect(harness.row('1')).toHaveAttribute('aria-selected', 'false'); + expect(harness.row('3')).toHaveAttribute('aria-selected', 'false'); + [groupHeader(0), groupHeader(1), sumsRow(0), sumsRow(1)] + .forEach((row) => expect(row).not.toHaveAttribute('aria-selected')); + + harness.selection.reset(); + + await waitFor(() => expect(harness.row('2')).toHaveAttribute('aria-selected', 'false')); + expect(relationRow).toHaveAttribute('aria-selected', 'false'); + }); + + it('exposes expansion on groups only', async () => { + mount(groupedFixtures); + await harness.render(); + + expect(groupHeader(0)).toHaveAttribute('aria-expanded', 'true'); + [harness.row('1'), sumsRow(0)].forEach((row) => expect(row).not.toHaveAttribute('aria-expanded')); + + toggleGroup(0); + await waitFor(() => expect(groupHeader(0)).toHaveAttribute('aria-expanded', 'false')); + expect(groupHeader(1)).toHaveAttribute('aria-expanded', 'true'); + + toggleGroup(0); + await waitFor(() => expect(groupHeader(0)).toHaveAttribute('aria-expanded', 'true')); + }); + + it('exposes expansion on hierarchy parents only', async () => { + mount({ + workPackages: [parent, child, { id: '3' }], + relations: [{ from: '1', to: '3', type: 'follows', reverseType: 'precedes' }], + columns: ['id', 'subject', relationColumn], + showHierarchies: true, + }); + harness.expand('1', relationColumn.id); + await harness.render(); + + expect(harness.row('1')).toHaveAttribute('aria-expanded', 'true'); + [harness.row('2'), harness.row('3'), harness.relationRow('1', '3')] + .forEach((row) => expect(row).not.toHaveAttribute('aria-expanded')); + + hierarchies().toggle('1'); + await waitFor(() => expect(harness.row('1')).toHaveAttribute('aria-expanded', 'false')); + + hierarchies().toggle('1'); + await waitFor(() => expect(harness.row('1')).toHaveAttribute('aria-expanded', 'true')); + }); + + it('hides collapsed occurrences before publication', async () => { + mount({ + ...groupedFixtures, + relations: [{ from: '1', to: '3', type: 'follows', reverseType: 'precedes' }], + columns: ['id', 'subject', relationColumn], + }); + harness.expand('1', relationColumn.id); + await harness.render(); + harness.click('1'); + const relationKey = relationOccurrenceKey('ofType', '1', '3'); + const groupMembers = () => [harness.row('1'), harness.row('2'), element(relationKey), sumsRow(0)]; + const ariaHidden = () => groupMembers().map((row) => row.getAttribute('aria-hidden')); + + const collapsed = atNextPublication(ariaHidden); + toggleGroup(0); + expect(await collapsed).toEqual(['true', 'true', 'true', 'true']); + expect(groupHeader(0)).not.toHaveAttribute('aria-hidden'); + expectExposed(harness.row('3')); + expectExposed(sumsRow(1)); + expect(harness.selection.isSelected('1')).toBe(true); + expect(harness.row('1')).toHaveAttribute('aria-selected', 'true'); + + const expanded = atNextPublication(ariaHidden); + toggleGroup(0); + expect(await expanded).toEqual([null, null, null, null]); + expect(harness.selection.isSelected('1')).toBe(true); + }); + + it('repaints replacement group headers with an empty visibility map', async () => { + mount(groupedFixtures); + await harness.render(); + const headerKey = groupOccurrenceKey(0, 'header'); + const header = groupHeader(0); + const headerOnly = harness.table.ledger.beginRender(); + headerOnly.append({ ...harness.table.ledger.byKey(headerKey)!, additionalClasses: [], workPackage: null }); + harness.tbody.replaceChildren(header); + harness.table.ledger.commit(headerOnly); + header.removeAttribute('role'); + header.removeAttribute('aria-level'); + header.removeAttribute('aria-expanded'); + + const replaced = atNextPublication(() => { + const current = groupHeader(0); + return { + size: harness.table.ledger.size, + replaced: current !== header, + role: current.getAttribute('role'), + level: current.getAttribute('aria-level'), + expanded: current.getAttribute('aria-expanded'), + }; + }); + toggleGroup(0); + + expect(await replaced).toEqual({ + size: 1, replaced: true, role: 'row', level: '1', expanded: 'false', + }); + }); + + it('paints initially collapsed groups', async () => { + mount(groupedFixtures); + harness.querySpace.collapsedGroups.putValue({ [harness.querySpace.groups.value![0].identifier]: true }); + await harness.render(); + + expect(groupHeader(0)).toHaveAttribute('aria-expanded', 'false'); + expectHidden(harness.row('1')); + expectHidden(sumsRow(0)); + expectExposed(harness.row('3')); + }); + + it('paints initially collapsed hierarchy structures', async () => { + mount({ + workPackages: [parent, child, { id: '3', ancestors: [parent, child] }, { id: '4' }], + showHierarchies: true, + }); + hierarchies().collapse('2'); + hierarchies().collapse('1'); + await harness.render(); + + expect(harness.row('1')).toHaveAttribute('aria-expanded', 'false'); + expectHidden(harness.row('2')); + expectHidden(harness.row('3')); + expectExposed(harness.row('4')); + + hierarchies().expand('1'); + + await waitFor(() => expectExposed(harness.row('2'))); + expect(harness.row('1')).toHaveAttribute('aria-expanded', 'true'); + expect(harness.row('2')).toHaveAttribute('aria-expanded', 'false'); + expect(harness.row('2')).toHaveAttribute('aria-level', '2'); + expectHidden(harness.row('3')); + expect(harness.row('3')).toHaveAttribute('aria-level', '3'); + }); + + it('stops painting after teardown', async () => { + mount(groupedFixtures); + await harness.render(); + const row = harness.row('1'); + const workPackage = resource('1'); + expect(row).toHaveAttribute('aria-selected', 'false'); + + harness.table.redrawTable(); + expect(harness.table.hasPendingRender).toBe(true); + harness.table.destroy(); + await new Promise((resolve) => { requestAnimationFrame(resolve); }); + row.removeAttribute('aria-level'); + harness.selection.replaceSelection('1'); + const before = harness.tbody.outerHTML; + + harness.table.refreshRows(workPackage); + const semantics = harness.table.semantics!; + semantics.paint(); + semantics.paintWorkPackage('1'); + semantics.paintSelection(); + + expect(row).not.toHaveAttribute('aria-level'); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(harness.tbody.outerHTML).toBe(before); + }); + + it('keeps hidden state and level when a hidden occurrence refreshes', async () => { + mount(groupedFixtures); + await harness.render(); + toggleGroup(0); + await waitFor(() => expectHidden(harness.row('1'))); + const previousCells = Array.from(harness.row('1').cells); + + const refreshed = atNextRefresh(() => { + const row = harness.row('1'); + const cells = Array.from(row.cells); + return { + rebuiltCells: cells.some((cell) => !previousCells.includes(cell)), + cellRoles: cells.every((cell) => cell.getAttribute('role') === 'gridcell'), + hidden: row.getAttribute('aria-hidden'), + level: row.getAttribute('aria-level'), + role: row.getAttribute('role'), + selected: row.getAttribute('aria-selected'), + }; + }); + harness.table.refreshRows(resource('1')); + + expect(await refreshed).toEqual({ + rebuiltCells: true, cellRoles: true, hidden: 'true', level: '2', role: 'row', selected: 'false', + }); + }); + + it('hides the results rowgroup when it holds only the drag placeholder', async () => { + mount({ workPackages: [], configuration: { dragAndDropEnabled: true } }); + await harness.render(); + + expectHidden(element(placeholderOccurrenceKey())); + ['role', 'aria-level', 'aria-selected', 'aria-expanded'] + .forEach((name) => expect(element(placeholderOccurrenceKey())).not.toHaveAttribute(name)); + expect(harness.tbody).toHaveAttribute('role', 'rowgroup'); + expectHidden(harness.tbody); + + await harness.render([{ id: '1' }]); + expectExposed(harness.tbody); + expect(within(harness.tbody).getAllByRole('row')).toEqual([harness.row('1')]); + + await harness.render([]); + expectHidden(harness.tbody); + }); + + it('pins relation rows of a collapsed hierarchy parent', async () => { + mount({ + workPackages: [parent, child, { id: '3' }], + relations: [{ from: '1', to: '3', type: 'follows', reverseType: 'precedes' }], + columns: ['id', 'subject', relationColumn], + showHierarchies: true, + }); + harness.expand('1', relationColumn.id); + await harness.render(); + + hierarchies().collapse('1'); + + await waitFor(() => expect(harness.row('1')).toHaveAttribute('aria-expanded', 'false')); + expectExposed(harness.relationRow('1', '3')); + expect(harness.relationRow('1', '3')).toHaveAttribute('aria-level', '2'); + expectHidden(harness.row('2')); + }); + + describe('when a collapse lands on a queued render', () => { + const queueRedraw = () => { + const frames:(() => void)[] = []; + vi.spyOn(harness.table.uiWork, 'frame').mockImplementation((callback) => { frames.push(callback); }); + harness.table.redrawTable(); + return () => frames.splice(0).forEach((callback) => callback()); + }; + + const collapseState = (parentRow:() => HTMLElement, childId:string, before:HTMLElement) => () => ({ + committed: harness.row(childId) !== before, + expanded: parentRow().getAttribute('aria-expanded'), + hidden: harness.row(childId).getAttribute('aria-hidden'), + ledgerHidden: harness.table.ledger.byWorkPackageId(childId)[0].hidden, + display: getComputedStyle(harness.row(childId)).display, + }); + + const collapsed = { + committed: true, expanded: 'false', hidden: 'true', ledgerHidden: true, display: 'none', + }; + + const raced = { + committed: true, expanded: null, hidden: null, ledgerHidden: false, display: 'table-row', + }; + + const releaseAfter = async (state:() => T, collapse:() => void, release:() => void) => { + collapse(); + const published = atNextPublication(state); + release(); + return { published: await published, final: state() }; + }; + + it('publishes a collapsed group with its rows hidden', async () => { + mount(groupedFixtures); + await harness.render(); + const state = collapseState(() => groupHeader(0), '1', harness.row('1')); + + expect(await releaseAfter(state, () => toggleGroup(0), queueRedraw())).toEqual({ published: collapsed, final: collapsed }); + }); + + it('publishes a collapsed hierarchy parent with its children hidden', async () => { + mount({ workPackages: [parent, child], showHierarchies: true }); + await harness.render(); + const state = collapseState(() => harness.row('1'), '2', harness.row('2')); + + expect(await releaseAfter(state, () => hierarchies().collapse('1'), queueRedraw())).toEqual({ published: collapsed, final: collapsed }); + }); + + it('keeps the capability-off group race unchanged', async () => { + mount({ ...groupedFixtures, configuration: { keyboardNavigationEnabled: false } }); + await harness.render(); + const state = collapseState(() => groupHeader(0), '1', harness.row('1')); + + expect(await releaseAfter(state, () => toggleGroup(0), queueRedraw())).toEqual({ published: raced, final: raced }); + }); + + it('keeps the capability-off hierarchy race unchanged', async () => { + mount({ workPackages: [parent, child], showHierarchies: true, configuration: { keyboardNavigationEnabled: false } }); + await harness.render(); + const state = collapseState(() => harness.row('1'), '2', harness.row('2')); + + expect(await releaseAfter(state, () => hierarchies().collapse('1'), queueRedraw())).toEqual({ published: raced, final: raced }); + }); + }); + + it('leaves capability-off tables untouched', async () => { + harness = buildTable({ ...groupedFixtures, configuration: { dragAndDropEnabled: true } }); + await harness.render(); + harness.click('1'); + toggleGroup(0); + await waitFor(() => expect(harness.row('1')).not.toBeVisible()); + + expect(harness.table.semantics).toBeNull(); + const rows = Array.from(harness.tbody.rows); + [harness.tbody, ...rows, ...rows.flatMap((row) => Array.from(row.cells))].forEach((node) => { + ['role', 'aria-level', 'aria-selected', 'aria-expanded', 'aria-hidden'] + .forEach((name) => expect(node).not.toHaveAttribute(name)); + }); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.ts new file mode 100644 index 000000000000..bdca74f001ca --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.ts @@ -0,0 +1,108 @@ +//-- 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 { WorkPackageViewSelectionService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service'; +import { isSelectableOccurrence } from 'core-app/features/work-packages/routing/wp-view-base/view-services/rendered-occurrences'; +import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; +import { expandedOf, rowKindOf } from '../helpers/row-expansion'; +import { placeholderOccurrenceKey, type RenderedOccurrence } from '../rendered-occurrence-ledger'; +import type { WorkPackageTable } from '../wp-fast-table'; + +function setOrRemove(element:HTMLElement, name:string, value:string|null):void { + if (value === null) { + element.removeAttribute(name); + } else { + element.setAttribute(name, value); + } +} + +export function paintRowRole(row:HTMLTableRowElement, level:number, cellRole:'gridcell'|'columnheader' = 'gridcell'):void { + row.setAttribute('role', 'row'); + row.setAttribute('aria-level', String(level)); + Array.from(row.cells).forEach((cell) => cell.setAttribute('role', cellRole)); +} + +export function paintSectionExposure(section:HTMLTableSectionElement):void { + section.setAttribute('role', 'rowgroup'); + const exposed = Array.from(section.rows).some((row) => row.getAttribute('aria-hidden') !== 'true'); + setOrRemove(section, 'aria-hidden', exposed ? null : 'true'); +} + +export class TreegridSemantics { + @LazyInject() private readonly wpTableSelection:WorkPackageViewSelectionService; + + @LazyInject() private readonly wpTableHierarchies:WorkPackageViewHierarchiesService; + + public readonly injector:Injector = this.table.injector; + + constructor(private readonly table:WorkPackageTable) {} + + public paint():void { + if (this.table.destroyed) return; + this.table.ledger.occurrences.forEach((occurrence) => this.paintOccurrence(occurrence)); + paintSectionExposure(this.table.tbody); + } + + public paintWorkPackage(id:string):void { + if (this.table.destroyed) return; + this.table.ledger.byWorkPackageId(id).forEach((occurrence) => this.paintOccurrence(occurrence)); + } + + public paintSelection():void { + if (this.table.destroyed) return; + this.table.ledger.occurrences.forEach((occurrence) => this.paintSelected(occurrence)); + } + + private paintOccurrence(occurrence:RenderedOccurrence):void { + const row = occurrence.element; + if (!row) return; + + if (occurrence.key === placeholderOccurrenceKey()) { + row.setAttribute('aria-hidden', 'true'); + return; + } + + paintRowRole(row, occurrence.level); + setOrRemove(row, 'aria-hidden', occurrence.hidden ? 'true' : null); + setOrRemove(row, 'aria-expanded', this.expansionOf(occurrence)); + this.paintSelected(occurrence); + } + + private paintSelected(occurrence:RenderedOccurrence):void { + if (occurrence.element && isSelectableOccurrence(occurrence)) { + occurrence.element.setAttribute('aria-selected', String(this.wpTableSelection.isSelected(occurrence.workPackageId))); + } + } + + private expansionOf(occurrence:RenderedOccurrence):string|null { + const kind = rowKindOf(occurrence); + const expanded = kind === null ? null : expandedOf(this.table, this.wpTableHierarchies, occurrence, kind); + return expanded === null ? null : String(expanded); + } +} 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 8746c3c4ddf4..c3ca2f8eec7f 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 @@ -48,6 +48,7 @@ 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'; interface PendingRender { readonly pass:PrimaryRenderPass; @@ -94,6 +95,8 @@ export class WorkPackageTable { public keyboardNavigation:KeyboardNavigation|null = null; + public readonly semantics:TreegridSemantics|null = this.configuration.keyboardNavigationEnabled ? new TreegridSemantics(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. */ @@ -205,12 +208,19 @@ export class WorkPackageTable { public setHidden(updates:ReadonlyMap):void { if (this.destroyed) return; + this.rebuildPendingRenderForSemantics(); if (updates.size === 0) return; this.keyboardNavigation?.captureOwnership(); this.ledger.setHidden(updates); this.publishRendered(); } + private rebuildPendingRenderForSemantics():void { + if (this.semantics && this.pendingRender) { + this.performRenderPass({ timeline: this.pendingRender.timeline }); + } + } + /** * Redraw single rows for a given work package being updated. */ @@ -233,6 +243,7 @@ export class WorkPackageTable { }); if (replaced > 0) { + this.semantics?.paintWorkPackage(workPackage.id!); this.notifyRowsRefreshed(workPackage.id!); } } @@ -274,6 +285,7 @@ export class WorkPackageTable { } private publishRendered():void { + this.semantics?.paint(); this.querySpace.tableRendered.putValue(this.ledger.snapshot()); } diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/rendered-occurrences.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/rendered-occurrences.ts index a74e850ae153..fe47860a3af2 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/rendered-occurrences.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/rendered-occurrences.ts @@ -45,8 +45,12 @@ export function sameOccurrence(a:RenderedWorkPackage, b:RenderedWorkPackage):boo return a.workPackageId === b.workPackageId && a.classIdentifier === b.classIdentifier; } +export function isSelectableOccurrence>(row:T):row is T & { workPackageId:string } { + return row.workPackageId !== null; +} + export function selectableOccurrences(rows:readonly RenderedWorkPackage[]):RenderedWorkPackage[] { - return rows.filter((row) => row.workPackageId !== null); + return rows.filter(isSelectableOccurrence); } export function anchoredOccurrence( From e819c7adfe5c2ff5ba542e01c41ab6698db10a72 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 24 Oct 2026 01:30:08 +0100 Subject: [PATCH 05/13] [DREAM-885] Make internal cells readable in rows Adds screen-reader text for the baseline status next to the decorative icon on keyboard-enabled tables, and rebuilds the baseline cell on row refresh there so icon and text follow the same resource. Gives every table a unique domId. Group sums and totals labels take an ID derived from it, and non-empty navigable sum value cells are described by their label. The totals row directive reconciles its row role, cell roles and aria-hidden, plus its section's exposure, when a table arrives and after every clear or render, before notifying the refresh. A schema that resolves after sums were disabled no longer renders the row. Replacement drag handles keep the gridcell role. https://community.openproject.org/wp/DREAM-885 --- .../baseline/baseline-column-builder.ts | 65 +++---- .../drag-drop-handle-render-pass.ts | 4 + .../modes/grouped/group-sums-builder.ts | 22 ++- .../modes/grouped/grouped-sums-row.spec.ts | 73 +++++++- .../builders/rows/single-row-builder.spec.ts | 99 +++++++++++ .../builders/rows/single-row-builder.ts | 12 +- .../semantics/treegrid-semantics.spec.ts | 19 ++ .../components/wp-fast-table/wp-fast-table.ts | 3 + .../wp-table-sums-row.directive.spec.ts | 167 +++++++++++++++++- .../wp-table-sums-row.directive.ts | 23 ++- 10 files changed, 443 insertions(+), 44 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/baseline/baseline-column-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/baseline/baseline-column-builder.ts index 8be030bdf0dd..d6ac2d6808bf 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/baseline/baseline-column-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/baseline/baseline-column-builder.ts @@ -35,6 +35,8 @@ import { QueryColumn } from 'core-app/features/work-packages/components/wp-query import { octiconElement } from 'core-app/shared/helpers/op-icon-builder'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { WorkPackageTableConfiguration } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; +import { keyboardNavigationEnabled } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; import { getBaselineState } from '../../../wp-baseline/baseline-helpers'; import { opArrowInIconData, @@ -44,6 +46,24 @@ import { export const baselineCellName = 'op-table-baseline--column-cell'; +const baselineChanges = { + added: { + icon: opArrowInIconData, + className: 'op-table-baseline--icon-added', + label: 'js.work_packages.baseline.addition_label', + }, + removed: { + icon: replyIconData, + className: 'op-table-baseline--icon-removed', + label: 'js.work_packages.baseline.removal_label', + }, + updated: { + icon: opTriangleDeltaIconData, + className: 'op-table-baseline--icon-changed', + label: 'js.work_packages.baseline.modification_label', + }, +}; + export class BaselineColumnBuilder { @LazyInject() states:States; @@ -53,7 +73,10 @@ export class BaselineColumnBuilder { @LazyInject() I18n:I18nService; - constructor(public readonly injector:Injector) { + constructor( + public readonly injector:Injector, + private readonly configuration:WorkPackageTableConfiguration, + ) { } public build(workPackage:WorkPackageResource, column:QueryColumn) { @@ -68,40 +91,22 @@ export class BaselineColumnBuilder { return td; } - // Check if added - const icon = this.changeIcon(workPackage); - if (icon) { - td.appendChild(icon); - } - - return td; - } - - private changeIcon( - workPackage:WorkPackageResource, - ):HTMLElement|null { const state = getBaselineState(workPackage, this.schemaCache); - if (state === 'added') { - return octiconElement(opArrowInIconData, - 'small', - 'op-table-baseline--icon-added', - this.I18n.t('js.work_packages.baseline.addition_label')); + if (!state) { + return td; } - if (state === 'removed') { - return octiconElement(replyIconData, - 'small', - 'op-table-baseline--icon-removed', - this.I18n.t('js.work_packages.baseline.removal_label')); - } + const { icon, className, label } = baselineChanges[state]; + const text = this.I18n.t(label); + td.appendChild(octiconElement(icon, 'small', className, text)); - if (state === 'updated') { - return octiconElement(opTriangleDeltaIconData, - 'small', - 'op-table-baseline--icon-changed', - this.I18n.t('js.work_packages.baseline.modification_label')); + if (keyboardNavigationEnabled(this.configuration)) { + const status = document.createElement('span'); + status.classList.add('sr-only'); + status.textContent = text; + td.appendChild(status); } - return null; + return td; } } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/drag-and-drop/drag-drop-handle-render-pass.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/drag-and-drop/drag-drop-handle-render-pass.ts index e47ea7a0f254..75150ee482d3 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/drag-and-drop/drag-drop-handle-render-pass.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/drag-and-drop/drag-drop-handle-render-pass.ts @@ -34,6 +34,7 @@ import { WorkPackageViewColumnsService } from 'core-app/features/work-packages/r import { WorkPackageNotificationService } from 'core-app/features/work-packages/services/notifications/work-package-notification.service'; import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; import { QueryOrder } from 'core-app/core/apiv3/endpoints/queries/apiv3-query-order'; +import { keyboardNavigationEnabled } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; import { PrimaryRenderPass } from '../primary-render-pass'; export class DragDropHandleRenderPass { @@ -75,6 +76,9 @@ public readonly injector:Injector, const handle = this.dragDropHandleBuilder.build(latestWorkPackage, positions[workPackage.id!]); if (handle && element) { + if (keyboardNavigationEnabled(this.table.configuration)) { + handle.setAttribute('role', 'gridcell'); + } element.replaceChild(handle, element.firstElementChild!); } }); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts index 254e2c2f3a92..139804fd86c9 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts @@ -52,22 +52,29 @@ export class GroupSumsBuilder extends SingleRowBuilder { const tr:HTMLTableRowElement = document.createElement('tr'); tr.classList.add('wp-table--sums-row', 'wp-table--row', groupedRowClassName(group.index)); - this.renderColumns(group.sums, tr); + this.renderColumns(group.sums, tr, group.index); return tr; } - public renderColumns(sums:GroupObject['sums'], tr:HTMLTableRowElement) { + public renderColumns(sums:GroupObject['sums'], tr:HTMLTableRowElement, labelSuffix:number|'total') { + const { configuration } = this.workPackageTable; + const labelId = keyboardNavigationEnabled(configuration) ? `${this.workPackageTable.domId}-sum-${labelSuffix}` : null; + this.augmentedColumns.forEach((column, i:number) => { const td = document.createElement('td'); - const { configuration } = this.workPackageTable; - if (keyboardNavigationEnabled(configuration) && isNavigableColumn(column, configuration)) { + const navigable = keyboardNavigationEnabled(configuration) && isNavigableColumn(column, configuration); + if (navigable) { td.classList.add(navigableCellClassName); } const div = this.renderContent(sums, column.id, this.sumsSchema[column.id] as IFieldSchema); + if (navigable && labelId && div.hasChildNodes()) { + td.setAttribute('aria-describedby', labelId); + } + if (i === 0) { - this.appendFirstLabel(div); + this.appendFirstLabel(div, labelId); } td.appendChild(div); @@ -75,10 +82,13 @@ export class GroupSumsBuilder extends SingleRowBuilder { }); } - private appendFirstLabel(div:HTMLElement) { + private appendFirstLabel(div:HTMLElement, id:string|null) { const span = document.createElement('span'); span.textContent = `${this.text.sum}`; span.title = `${this.text.sum}`; + if (id) { + span.id = id; + } div.prepend(span); } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts index bd7e8599011f..93880c307339 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-sums-row.spec.ts @@ -163,7 +163,7 @@ describe('Grouped table sums physical columns', () => { }, baseline); const expected = 2 + Number(drag) + Number(baseline) + Number(trailing); const totals = document.createElement('tr'); - new GroupSumsBuilder(harness.injector, harness.table).renderColumns({ storyPoints: 34 }, totals); + new GroupSumsBuilder(harness.injector, harness.table).renderColumns({ storyPoints: 34 }, totals, 'total'); expect(spannedColumns(harness.row('2').nextElementSibling as HTMLTableRowElement)).toBe(expected); expect(spannedColumns(totals)).toBe(expected); @@ -177,3 +177,74 @@ describe('Grouped table sums physical columns', () => { expect((harness.row('2').nextElementSibling as HTMLTableRowElement).cells).toHaveLength(2 + 1 + 1 + 1); }); }); + +describe('Grouped table sums labels on an enabled table', () => { + const harnesses:TableHarness[] = []; + + afterEach(async () => { + await Promise.all(harnesses.splice(0).map((table) => table.destroy())); + }); + + const mount = async (dragAndDropEnabled:boolean) => { + const harness = buildTable({ + workPackages: [ + { id: '1', attributes: { status: newStatus } }, + { id: '2', attributes: { status: inProgressStatus } }, + ], + groups: [ + { value: 'New', href: newStatus.href, count: 1, sums: { storyPoints: 13 } }, + { value: 'In progress', href: inProgressStatus.href, count: 1, sums: { storyPoints: 21 } }, + ], + columns: ['id', 'subject', 'storyPoints'], + translations: { 'js.label_sum': 'Sum' }, + configuration: { keyboardNavigationEnabled: true, dragAndDropEnabled }, + }); + harnesses.push(harness); + await harness.render(); + return harness; + }; + + const sumsRowAfter = (harness:TableHarness, workPackageId:string) => harness.row(workPackageId).nextElementSibling as HTMLTableRowElement; + const sumLabel = (row:HTMLTableRowElement) => within(row).getByText('Sum'); + + it.each([true, false])('names the sums row and describes its values with drag and drop %s', async (drag) => { + const harness = await mount(drag); + const row = sumsRowAfter(harness, '1'); + const valueCell = within(row).getByText('13').closest('td')!; + + expect(row).toHaveAccessibleName(expect.stringContaining('Sum')); + expect(row).toHaveAccessibleName(expect.stringContaining('13')); + expect(valueCell).toHaveAccessibleDescription('Sum'); + Array.from(row.cells).filter((cell) => cell !== valueCell).forEach((cell) => { + expect(cell).not.toHaveAttribute('aria-describedby'); + }); + }); + + it('gives every sums label an ID unique to its table and group', async () => { + const first = await mount(false); + const second = await mount(false); + const ids = [ + sumLabel(sumsRowAfter(first, '1')).id, + sumLabel(sumsRowAfter(first, '2')).id, + sumLabel(sumsRowAfter(second, '1')).id, + ]; + + ids.forEach((id) => expect(id).toBeTruthy()); + expect(new Set(ids).size).toBe(3); + }); + + it('adds no label IDs or descriptions on a capability-off table', async () => { + const harness = buildTable({ + workPackages: [{ id: '1', attributes: { status: newStatus } }], + groups: [{ value: 'New', href: newStatus.href, count: 1, sums: { storyPoints: 13 } }], + columns: ['id', 'subject', 'storyPoints'], + translations: { 'js.label_sum': 'Sum' }, + }); + harnesses.push(harness); + await harness.render(); + const row = sumsRowAfter(harness, '1'); + + expect(sumLabel(row)).not.toHaveAttribute('id'); + Array.from(row.cells).forEach((cell) => expect(cell).not.toHaveAttribute('aria-describedby')); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.spec.ts index c9c77a5522d5..2d8888c6598b 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.spec.ts @@ -28,8 +28,10 @@ import { States } from 'core-app/core/states/states.service'; import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { fireEvent, waitFor, within } from '@testing-library/dom'; import { nextFrame } from 'core-common/testing/timing'; import { buildTable, TableHarness } from '../../testing/table-harness'; +import { buildWorkPackage } from '../../testing/work-package-fixture'; import { SingleRowBuilder } from './single-row-builder'; describe('SingleRowBuilder with two tables showing the same work package', () => { @@ -120,3 +122,100 @@ describe('SingleRowBuilder physical columns', () => { expect(new SingleRowBuilder(harness.injector, harness.table).placeholderRow.cells[0].colSpan).toBe(3 + 1 + 1 + 1); }); }); + +describe('SingleRowBuilder baseline status text', () => { + let harness:TableHarness; + + afterEach(() => harness.destroy()); + + const labels = { + added: 'Added to view within the comparison time period', + removed: 'Removed from view within the comparison time period', + updated: 'Modified within the comparison time period', + }; + + const mount = async (keyboardNavigationEnabled:boolean) => { + harness = buildTable({ + workPackages: [{ id: '1', baseline: 'added' }, { id: '2', baseline: 'removed' }, { id: '3', baseline: 'updated' }, { id: '4' }], + baseline: true, + translations: { + 'js.work_packages.baseline.addition_label': labels.added, + 'js.work_packages.baseline.removal_label': labels.removed, + 'js.work_packages.baseline.modification_label': labels.updated, + }, + configuration: { keyboardNavigationEnabled }, + }); + await harness.render(); + }; + + const baselineCell = (workPackageId:string) => harness.row(workPackageId).querySelector('td.op-table-baseline--column-cell')!; + + it.each([ + ['1', 'added'], + ['2', 'removed'], + ['3', 'updated'], + ] as const)('names row %s with its %s status', async (workPackageId, state) => { + await mount(true); + + expect(harness.row(workPackageId)).toHaveAccessibleName(expect.stringContaining(labels[state])); + }); + + it('adds no status text to unchanged rows', async () => { + await mount(true); + + expect(baselineCell('4')).toBeEmptyDOMElement(); + Object.values(labels).forEach((label) => { + expect(harness.row('4')).not.toHaveAccessibleName(expect.stringContaining(label)); + }); + }); + + it('rebuilds icon and status text together when the baseline state changes', async () => { + await mount(true); + + harness.table.refreshRows(buildWorkPackage({ id: '1', baseline: 'removed' })); + + expect(baselineCell('1').querySelector('.op-table-baseline--icon-removed')).toBeInTheDocument(); + expect(baselineCell('1').querySelector('.op-table-baseline--icon-added')).not.toBeInTheDocument(); + expect(harness.row('1')).toHaveAccessibleName(expect.stringContaining(labels.removed)); + expect(harness.row('1')).not.toHaveAccessibleName(expect.stringContaining(labels.added)); + expect(baselineCell('1')).toHaveAttribute('role', 'gridcell'); + }); + + it('keeps the baseline cell of a capability-off table on refresh', async () => { + await mount(false); + const cell = baselineCell('1'); + + harness.table.refreshRows(buildWorkPackage({ id: '1', baseline: 'removed' })); + + expect(baselineCell('1')).toBe(cell); + expect(within(cell).queryByText(labels.added)).not.toBeInTheDocument(); + }); +}); + +describe('SingleRowBuilder refresh with an active editor', () => { + let harness:TableHarness; + + afterEach(() => harness.destroy()); + + it('retains the editor cell with its role, the row level and selection', async () => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }], + editing: {}, + configuration: { keyboardNavigationEnabled: true }, + }); + await harness.render(); + harness.click('1'); + const subjectCell = harness.row('1').querySelector('td.subject')!; + subjectCell.focus(); + fireEvent.keyDown(subjectCell, { key: 'Enter' }); + await waitFor(() => expect(within(harness.row('1')).getByRole('textbox')).toHaveFocus()); + expect(within(harness.row('1')).getByRole('textbox').closest('td')).toBe(subjectCell); + + harness.table.refreshRows(harness.injector.get(States).workPackages.get('1').value!); + + expect(within(harness.row('1')).getByRole('textbox').closest('td')).toBe(subjectCell); + expect(subjectCell).toHaveAttribute('role', 'gridcell'); + expect(harness.row('1')).toHaveAttribute('aria-level', '1'); + expect(harness.row('1')).toHaveAttribute('aria-selected', 'true'); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts index 4dff17c930a9..8dabe9cf4f00 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts @@ -88,7 +88,7 @@ export class SingleRowBuilder { protected contextLinkBuilder = new TableActionRenderer(this.injector, this.workPackageTable.configuration); // Baseline column builder - protected baselineColumnBuilder = new BaselineColumnBuilder(this.injector); + protected baselineColumnBuilder = new BaselineColumnBuilder(this.injector, this.workPackageTable.configuration); // Build the augmented columns set to render with protected readonly augmentedColumns:QueryColumn[] = this.buildAugmentedColumns(); @@ -241,7 +241,7 @@ export class SingleRowBuilder { // But only do that, if the column existed before. Sometimes, e.g. when lacking permissions // the column was not correctly created (with the intended classes). This code then // increases the robustness. - if ((column.id.startsWith('__internal') || this.isColumnBeingEdited(workPackage, column)) && oldTd) { + if ((this.retainsOnRefresh(column) || this.isColumnBeingEdited(workPackage, column)) && oldTd) { newCells.push(oldTd); return; } @@ -258,6 +258,14 @@ export class SingleRowBuilder { return row; } + private retainsOnRefresh(column:QueryColumn):boolean { + if (column.id === internalBaselineColumn.id && keyboardNavigationEnabled(this.workPackageTable.configuration)) { + return false; + } + + return column.id.startsWith('__internal'); + } + protected isColumnBeingEdited(workPackage:WorkPackageResource, column:QueryColumn) { const form = this.workPackageTable.editing.forms[workPackage.id!]; diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.spec.ts index de6728ba8540..a9d2f605dbe2 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.spec.ts @@ -33,6 +33,9 @@ import { WorkPackageViewCollapsedGroupsService } from 'core-app/features/work-pa import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service'; import { buildTable, TableHarness, TableHarnessOptions } from '../testing/table-harness'; import { WorkPackageFixture } from '../testing/work-package-fixture'; +import { WorkPackageViewOrderService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-order.service'; +import { QueryOrder } from 'core-app/core/apiv3/endpoints/queries/apiv3-query-order'; +import { navigableCellClassName } from '../handlers/state/keyboard/roving-tabindex'; import { groupOccurrenceKey, placeholderOccurrenceKey, relationOccurrenceKey, wpOccurrenceKey } from '../rendered-occurrence-ledger'; const newStatus = { href: '/api/v3/statuses/1' }; @@ -359,6 +362,22 @@ describe('TreegridSemantics', () => { expectHidden(harness.row('2')); }); + it('keeps the cell role on drag handles that replace the first cell', async () => { + let resolvePositions:(positions:QueryOrder) => void = () => undefined; + mount({ configuration: { dragAndDropEnabled: true } }); + vi.spyOn(harness.injector.get(WorkPackageViewOrderService), 'positionsFor') + .mockReturnValue(new Promise((resolve) => { resolvePositions = resolve; })); + await harness.render(); + const placeholderCell = harness.row('1').cells[0]; + + resolvePositions({ 1: 0, 2: 1, 3: 2 }); + + await waitFor(() => expect(harness.row('1').cells[0]).not.toBe(placeholderCell)); + expect(harness.row('1').cells[0]).toHaveClass('wp-table--sort-td'); + expect(harness.row('1').cells[0]).toHaveAttribute('role', 'gridcell'); + expect(harness.row('1').cells[0]).not.toHaveClass(navigableCellClassName); + }); + describe('when a collapse lands on a queued render', () => { const queueRedraw = () => { const frames:(() => void)[] = []; 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 c3ca2f8eec7f..e5cf0c618d60 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 @@ -27,6 +27,7 @@ import { DestroyRef, Injector } from '@angular/core'; import { Subject } from 'rxjs'; +import { uniqueId } from 'lodash-es'; import { runCleanup } from 'core-app/shared/helpers/angular/owned-ui-cleanup'; import { TableUiWork } from './table-ui-work'; import { I18nService } from 'core-app/core/i18n/i18n.service'; @@ -66,6 +67,8 @@ export class WorkPackageTable { @LazyInject() workPackageViewCollapsedGroupsService:WorkPackageViewCollapsedGroupsService; + public readonly domId = uniqueId('wp-table-'); + public originalRows:string[] = []; public colspan:number; 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 6830bfe92c0b..f56f699d9220 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 @@ -25,10 +25,12 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { Component, Injector } from '@angular/core'; +import { ChangeDetectorRef, Component, Injector } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { Subject } from 'rxjs'; +import { firstValueFrom, Subject } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { within } from '@testing-library/dom'; 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'; @@ -181,3 +183,164 @@ describe('WorkPackageTableSumsRowController input readiness', () => { expect(events).toEqual([]); }); }); + +describe('WorkPackageTableSumsRowController semantics', () => { + let fixture:ComponentFixture; + let directive:WorkPackageTableSumsRowController; + let hostChangeDetector:ChangeDetectorRef; + let row:HTMLTableRowElement; + let schemaResponse:Subject; + const tables:TableHarness[] = []; + const schema = { + storyPoints: { type: 'Integer', name: 'storyPoints', writable: false, hasDefault: false }, + } as unknown as SchemaResource; + + const setUp = async (configuration:Record) => { + schemaResponse = new Subject(); + await TestBed.configureTestingModule({ + declarations: [SumsHostComponent, WorkPackageTableSumsRowController], + providers: harnessProviders(new FakeDragAndDropService(), { workPackages: [], translations: { 'js.label_total_sum': 'Total sum' } }), + }) + .overrideDirective(WorkPackageTableSumsRowController, { + add: { + providers: [ + SchemaCacheService, WorkPackageViewSumService, + { provide: HalResourceService, useValue: { createHalResourceOfClass: () => schema, get: () => schemaResponse } }, + ], + }, + }).compileComponents(); + initializeViewServices(TestBed.inject(Injector), buildQuery(['id', 'subject', 'storyPoints'], null, false, false)); + const harness = buildTable({ workPackages: [], configuration }); + tables.push(harness); + fixture = TestBed.createComponent(SumsHostComponent); + const debug = fixture.debugElement.query(By.directive(WorkPackageTableSumsRowController)); + directive = debug.injector.get(WorkPackageTableSumsRowController); + hostChangeDetector = debug.injector.get(ChangeDetectorRef); + row = debug.nativeElement as HTMLTableRowElement; + TestBed.inject(IsolatedQuerySpace).results.putValue({ + elements: [], sumsSchema: { href: '/api/v3/sums/schema' }, totalSums: { storyPoints: 34 }, + } as unknown as WorkPackageCollectionResource); + return harness; + }; + + const attach = (harness:TableHarness, sums:boolean) => { + directive.wpTableSums.setEnabled(sums); + directive.workPackageTable = harness.table; + fixture.detectChanges(); + }; + + async function resolveSchema() { + schemaResponse.next(schema); + await nextTask(); + } + + const section = () => row.parentElement!; + const valueCell = () => within(row).getByText('34').closest('td')!; + + afterEach(async () => { + fixture.destroy(); + await Promise.all(tables.splice(0).map((table) => table.destroy())); + vi.restoreAllMocks(); + }); + + 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) => ({ + key, + role: row.getAttribute('role'), + level: row.getAttribute('aria-level'), + cellRoles: Array.from(row.cells).map((cell) => cell.getAttribute('role')), + rowHidden: row.getAttribute('aria-hidden'), + sectionRole: section().getAttribute('role'), + sectionHidden: section().getAttribute('aria-hidden'), + })))); + attach(harness, true); + + await resolveSchema(); + + expect(await painted).toEqual({ + key: totalsRowKey, + role: 'row', + level: '1', + cellRoles: Array.from(row.cells).map(() => 'gridcell'), + rowHidden: null, + sectionRole: 'rowgroup', + sectionHidden: null, + }); + expect(row.cells.length).toBeGreaterThan(0); + }); + + it('hides the empty row and section until its cells render, and again after clearing', async () => { + const harness = await setUp({ keyboardNavigationEnabled: true }); + attach(harness, false); + + expect(row).toBeEmptyDOMElement(); + expect(row).toHaveAttribute('aria-hidden', 'true'); + expect(section()).toHaveAttribute('aria-hidden', 'true'); + + directive.wpTableSums.setEnabled(true); + await resolveSchema(); + + expect(row).not.toHaveAttribute('aria-hidden'); + expect(section()).not.toHaveAttribute('aria-hidden'); + + directive.wpTableSums.setEnabled(false); + + expect(row).toBeEmptyDOMElement(); + expect(row).toHaveAttribute('aria-hidden', 'true'); + expect(section()).toHaveAttribute('aria-hidden', 'true'); + }); + + 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); + + await resolveSchema(); + + expect(row).toBeEmptyDOMElement(); + expect(row).toHaveAttribute('aria-hidden', 'true'); + expect(section()).toHaveAttribute('aria-hidden', 'true'); + expect(refreshed).not.toHaveBeenCalled(); + }); + + it('stays hidden when the schema resolves after the table was destroyed', async () => { + const harness = await setUp({ keyboardNavigationEnabled: true }); + attach(harness, true); + harness.table.destroy(); + + await resolveSchema(); + + expect(row).toBeEmptyDOMElement(); + expect(row).toHaveAttribute('aria-hidden', 'true'); + expect(section()).toHaveAttribute('aria-hidden', 'true'); + }); + + it.each([true, false])('names the totals row and describes its values with drag and drop %s', async (dragAndDropEnabled) => { + const harness = await setUp({ keyboardNavigationEnabled: true, dragAndDropEnabled }); + attach(harness, true); + + await resolveSchema(); + hostChangeDetector.detectChanges(); + + expect(row).toHaveAccessibleName(expect.stringContaining('Total sum')); + expect(row).toHaveAccessibleName(expect.stringContaining('34')); + expect(within(row).getByText('Total sum').id).toBe(`${harness.table.domId}-sum-total`); + expect(valueCell()).toHaveAccessibleDescription('Total sum'); + }); + + it('leaves the totals row of a capability-off table untouched', async () => { + const harness = await setUp({}); + attach(harness, true); + + await resolveSchema(); + + expect(row).toHaveTextContent('Total sum'); + [row, section(), ...Array.from(row.cells)].forEach((node) => { + ['role', 'aria-level', 'aria-hidden', 'aria-describedby'].forEach((name) => expect(node).not.toHaveAttribute(name)); + }); + expect(within(row).getByText('Total sum')).not.toHaveAttribute('id'); + }); +}); 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 a6a955418722..d47959defebc 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 @@ -26,7 +26,7 @@ //++ import { AfterViewInit, Directive, DestroyRef, ElementRef, Injector, Input, inject } from '@angular/core'; -import { switchMap } from 'rxjs/operators'; +import { switchMap, tap } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { States } from 'core-app/core/states/states.service'; @@ -41,6 +41,8 @@ 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'; +import { paintRowRole, paintSectionExposure } from 'core-app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics'; @Directive({ // eslint-disable-next-line @angular-eslint/directive-selector @@ -98,6 +100,7 @@ export class WorkPackageTableSumsRowController implements AfterViewInit { this.tables .pipe( + tap((table) => this.reconcileSemantics(table)), switchMap((table) => combineLatest([ this.wpTableColumns.live$(), this.wpTableSums.live$(), @@ -113,19 +116,33 @@ export class WorkPackageTableSumsRowController implements AfterViewInit { void this.schemaCache .ensureLoaded(resource.sumsSchema.href!) .then((schema:SchemaResource) => { - if (table.destroyed || this.destroyRef.destroyed || this.workPackageTable !== table) return; + if (table.destroyed || this.destroyRef.destroyed || this.workPackageTable !== table || !this.wpTableSums.isEnabled) return; table.keyboardNavigation?.captureOwnership(); this.refresh(columns, resource, schema); + this.reconcileSemantics(table); table.notifyRowsRefreshed(totalsRowKey); }); } else if (this.element.hasChildNodes()) { table.keyboardNavigation?.captureOwnership(); this.clear(); + this.reconcileSemantics(table); table.notifyRowsRefreshed(totalsRowKey); } }); } + private reconcileSemantics(table:WorkPackageTable) { + if (table.destroyed || !keyboardNavigationEnabled(table.configuration)) return; + + if (this.isHidden || this.element.cells.length === 0) { + this.element.setAttribute('aria-hidden', 'true'); + } else { + this.element.removeAttribute('aria-hidden'); + } + paintRowRole(this.element, 1); + paintSectionExposure(this.element.parentElement as HTMLTableSectionElement); + } + private clear() { this.element.innerHTML = ''; } @@ -138,6 +155,6 @@ export class WorkPackageTableSumsRowController implements AfterViewInit { private render(columns:QueryColumn[], resource:WorkPackageCollectionResource, schema:SchemaResource) { this.groupSumsBuilder = new GroupSumsBuilder(this.injector, this.workPackageTable); this.groupSumsBuilder.text = this.text; - this.groupSumsBuilder.renderColumns(resource.totalSums!, this.element); + this.groupSumsBuilder.renderColumns(resource.totalSums!, this.element, 'total'); } } From bd8c096a3b4439d32ab447f6d8a33ff60adfe815 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 24 Oct 2026 01:30:09 +0100 Subject: [PATCH 06/13] [DREAM-885] Align hierarchy text with row levels Passes a separate label level to the hierarchy indicator text. On keyboard-enabled tables it reads the 1-based level that aria-level exposes, on initial build and refresh alike; indentation keeps the zero-based depth, and capability-off tables keep their text. https://community.openproject.org/wp/DREAM-885 --- .../hierarchy/hierarchy-collapse.spec.ts | 69 +++++++++++++++++++ .../hierarchy/single-hierarchy-row-builder.ts | 13 ++-- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts index 46b345b9ed7d..c0039756b1a5 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-collapse.spec.ts @@ -328,3 +328,72 @@ describe('Hierarchy table levels', () => { expect(levelOf('wp:5')).toBe(3); }); }); + +describe('Hierarchy indicator text', () => { + const harnesses:TableHarness[] = []; + + afterEach(async () => { + await Promise.all(harnesses.splice(0).map((table) => table.destroy())); + }); + + const mount = async (keyboardNavigationEnabled:boolean) => { + const harness = buildTable({ + workPackages: [parent, child, { id: '3', ancestors: [parent, child] }], + showHierarchies: true, + translations: { + 'js.work_packages.hierarchy.children_expanded': 'Hierarchy level %{level}, expanded.', + 'js.work_packages.hierarchy.children_collapsed': 'Hierarchy level %{level}, collapsed.', + 'js.work_packages.hierarchy.leaf': 'Work package leaf at level %{level}.', + }, + configuration: { keyboardNavigationEnabled }, + }); + harnesses.push(harness); + await harness.render(); + return harness; + }; + + const indicator = (harness:TableHarness, workPackageId:string) => harness.row(workPackageId).querySelector('.wp-table--hierarchy-span')!; + const subjectCell = (harness:TableHarness, workPackageId:string) => harness.row(workPackageId).querySelector('td.subject')!; + const levelText = (level:string|null) => new RegExp(`(Hierarchy level|leaf at level) ${level}[,.]`); + + const geometry = (harness:TableHarness, workPackageId:string) => ({ + width: indicator(harness, workPackageId).style.width, + indentation: indicator(harness, workPackageId).dataset.indentation, + minWidth: subjectCell(harness, workPackageId).style.minWidth, + containerWidth: subjectCell(harness, workPackageId).querySelector('.wp-table--cell-container')?.style.width, + }); + + it('reads the row level on an enabled table, also after a refresh', async () => { + const harness = await mount(true); + + expect(harness.row('1')).toHaveAccessibleName(expect.stringContaining('Hierarchy level 1,')); + ['1', '2', '3'].forEach((id) => { + expect(indicator(harness, id)).toHaveTextContent(levelText(harness.row(id).getAttribute('aria-level'))); + }); + expect(indicator(harness, '3')).toHaveTextContent('Work package leaf at level 3.'); + + ['1', '2', '3'].forEach((id) => harness.table.refreshRows(harness.injector.get(States).workPackages.get(id).value!)); + + expect(indicator(harness, '1')).toHaveTextContent('Hierarchy level 1,'); + expect(indicator(harness, '2')).toHaveTextContent('Hierarchy level 2,'); + expect(indicator(harness, '3')).toHaveTextContent('Work package leaf at level 3.'); + }); + + it('keeps the zero-based text of a capability-off table', async () => { + const harness = await mount(false); + + expect(indicator(harness, '1')).toHaveTextContent('Hierarchy level 0,'); + expect(indicator(harness, '2')).toHaveTextContent('Hierarchy level 1,'); + expect(indicator(harness, '3')).toHaveTextContent('Work package leaf at level 2.'); + }); + + it('indents an enabled table exactly like a capability-off table', async () => { + const enabled = await mount(true); + const disabled = await mount(false); + + ['1', '2', '3'].forEach((id) => { + expect(geometry(enabled, id)).toEqual(geometry(disabled, id)); + }); + expect(geometry(enabled, '3').containerWidth).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts index c9a1a7073e7a..c19fd1cf2240 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts @@ -151,7 +151,8 @@ export class SingleHierarchyRowBuilder extends SingleRowBuilder { private appendHierarchyIndicator(workPackage:WorkPackageResource, row:HTMLTableRowElement, level?:number):void { const ancestors = workPackage.getAncestors(); const hierarchyLevel = level === undefined || null ? ancestors.length : level; - const hierarchyElement = this.buildHierarchyIndicator(workPackage, row, hierarchyLevel); + const labelLevel = keyboardNavigationEnabled(this.workPackageTable.configuration) ? hierarchyLevel + 1 : hierarchyLevel; + const hierarchyElement = this.buildHierarchyIndicator(workPackage, row, hierarchyLevel, labelLevel); const subjectCell = row.querySelector('td.subject'); if (!subjectCell) return; @@ -168,9 +169,9 @@ export class SingleHierarchyRowBuilder extends SingleRowBuilder { } /** - * Build the hierarchy indicator at the given indentation level. + * Build the hierarchy indicator at the given indentation level, reading `labelLevel` in its text. */ - private buildHierarchyIndicator(workPackage:WorkPackageResource, row:HTMLTableRowElement, level:number):HTMLElement { + private buildHierarchyIndicator(workPackage:WorkPackageResource, row:HTMLTableRowElement, level:number, labelLevel:number):HTMLElement { const hierarchyIndicator = document.createElement('span'); const collapsed = this.wpTableHierarchies.collapsed(workPackage.id!); const tabindex = keyboardNavigationEnabled(this.workPackageTable.configuration) ? '-1' : '0'; @@ -185,17 +186,17 @@ export class SingleHierarchyRowBuilder extends SingleRowBuilder { ${this.text.expanded( - level, + labelLevel, )} ${this.text.collapsed( - level, + labelLevel, )} `; } else { hierarchyIndicator.innerHTML = ` - ${this.text.leaf(level)} + ${this.text.leaf(labelLevel)} `; } From 7ed4e32e474500fa1e1d72ce3532d57b7114c83e Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 24 Oct 2026 01:44:07 +0100 Subject: [PATCH 07/13] [DREAM-885] Name the table and its header row Gives keyboard-enabled work package tables the treegrid role, the "Work packages" name and a per-table sort description that lists every current criterion in order, including manual sorting and a primary criterion on a hidden column. Exposes the header as a level 1 row of named column headers and marks only the primary sorted header with aria-sort, sharing the decision with SortHeaderDirective through primarySortDirection. Adds the translations for these names, plus the relations button name used next. Capability-off tables keep their caption and native markup. https://community.openproject.org/wp/DREAM-885 --- config/locales/js-en.yml | 7 + .../sort-header/primary-sort-direction.ts | 50 ++++ .../sort-header/sort-header.directive.spec.ts | 43 ++- .../sort-header/sort-header.directive.ts | 32 +- .../wp-table/wp-table.component.html | 41 ++- .../wp-table/wp-table.component.spec.ts | 273 +++++++++++++++++- .../components/wp-table/wp-table.component.ts | 73 ++++- 7 files changed, 469 insertions(+), 50 deletions(-) create mode 100644 frontend/src/app/features/work-packages/components/wp-table/sort-header/primary-sort-direction.ts diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index a031761e4357..c71c96f8c981 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -1163,6 +1163,13 @@ en: title: "Share work package" table: configure_button: "Configure work package table" + grid_actions_header: "Actions" + grid_baseline_header: "Comparison status" + relations_button: "Relations in %{column} for %{work_package}" + sort_criterion_ascending: "%{column} ascending" + sort_criterion_descending: "%{column} descending" + sorted_by: "Sorted by %{criteria}." + sorted_manually: "Sorted manually." summary: "Table with rows of work package and columns of work package attributes." text_inline_edit: "Most cells of this table are buttons that activate inline-editing functionality of that attribute." text_select_hint: "Select boxes should be opened with 'ALT' and arrow keys." diff --git a/frontend/src/app/features/work-packages/components/wp-table/sort-header/primary-sort-direction.ts b/frontend/src/app/features/work-packages/components/wp-table/sort-header/primary-sort-direction.ts new file mode 100644 index 000000000000..47824137e318 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-table/sort-header/primary-sort-direction.ts @@ -0,0 +1,50 @@ +//-- 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 { + QUERY_SORT_BY_ASC, + QUERY_SORT_BY_DESC, + QuerySortByResource, +} from 'core-app/features/hal/resources/query-sort-by-resource'; +import { QueryColumn } from 'core-app/features/work-packages/components/wp-query/query-column'; + +export function primarySortDirection(sortBy:QuerySortByResource[], column:QueryColumn):'asc'|'desc'|null { + const primary = sortBy[0]; + + if (!primary?.column.href || primary.column.href !== column.href) { + return null; + } + + switch (primary.direction.href) { + case QUERY_SORT_BY_ASC: + return 'asc'; + case QUERY_SORT_BY_DESC: + return 'desc'; + default: + return null; + } +} diff --git a/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.spec.ts b/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.spec.ts index 6c15787830ce..9eed208e7f45 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.spec.ts @@ -34,7 +34,8 @@ import { TimezoneService } from 'core-app/core/datetime/timezone.service'; import { WeekdayService } from 'core-app/core/days/weekday.service'; import { DayResourceService } from 'core-app/core/state/days/day.service'; import { WeekdayResourceService } from 'core-app/core/state/days/weekday.service'; -import { QuerySortByResource, QUERY_SORT_BY_ASC } from 'core-app/features/hal/resources/query-sort-by-resource'; +import { QuerySortByResource, QUERY_SORT_BY_ASC, QUERY_SORT_BY_DESC } from 'core-app/features/hal/resources/query-sort-by-resource'; +import { QueryColumn } from 'core-app/features/work-packages/components/wp-query/query-column'; import { WorkPackageViewBaselineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-baseline.service'; import { WorkPackageViewGroupByService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-group-by.service'; import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service'; @@ -43,6 +44,7 @@ import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/q import { nextTask } from 'core-common/testing/timing'; import { buildQuery, buildTable, FakeDragAndDropService, harnessProviders, initializeViewServices, TableHarness } from '../../wp-fast-table/testing/table-harness'; import { SortHeaderDirective } from './sort-header.directive'; +import { primarySortDirection } from './primary-sort-direction'; describe('SortHeaderDirective input readiness', () => { let fixture:ComponentFixture; @@ -176,6 +178,25 @@ describe('SortHeaderDirective input readiness', () => { expect(detect).toHaveBeenCalledTimes(1); }); + it('marks its column only while it is the primary criterion', async () => { + fixture.detectChanges(); + await nextTask(); + const column = fixture.componentInstance.headerColumn; + const status = { id: 'status', href: '/api/v3/queries/columns/status' } as QueryColumn; + const sortBy = TestBed.inject(WorkPackageViewSortByService); + + sortBy.update([ + { column: status, direction: { href: QUERY_SORT_BY_ASC } }, + { column, direction: { href: QUERY_SORT_BY_DESC } }, + ] as QuerySortByResource[]); + expect(fixture.nativeElement).not.toHaveClass('active-column'); + expect(fixture.componentInstance.directionClass).toBe(''); + + sortBy.update([{ column, direction: { href: QUERY_SORT_BY_DESC } }] as QuerySortByResource[]); + expect(fixture.nativeElement).toHaveClass('active-column'); + expect(fixture.componentInstance.directionClass).toBe('desc'); + }); + it.each(['header', 'table'])('stops all header streams after destroying the %s', async (owner) => { fixture.detectChanges(); await nextTask(); @@ -194,3 +215,23 @@ describe('SortHeaderDirective input readiness', () => { expect(baselineEffects).not.toHaveBeenCalled(); }); }); + +describe('primarySortDirection', () => { + const subject = { id: 'subject', href: '/api/v3/queries/columns/subject' } as QueryColumn; + const status = { id: 'status', href: '/api/v3/queries/columns/status' } as QueryColumn; + const criterion = (column:QueryColumn, href:string) => ({ column, direction: { href } }) as QuerySortByResource; + + it.each([ + [QUERY_SORT_BY_ASC, 'asc'], + [QUERY_SORT_BY_DESC, 'desc'], + ])('reads the primary criterion %s as %s', (href, expected) => { + expect(primarySortDirection([criterion(subject, href), criterion(status, QUERY_SORT_BY_ASC)], subject)).toBe(expected); + }); + + it('ignores secondary criteria and unsorted columns', () => { + const sortBy = [criterion(subject, QUERY_SORT_BY_ASC), criterion(status, QUERY_SORT_BY_DESC)]; + + expect(primarySortDirection(sortBy, status)).toBeNull(); + expect(primarySortDirection([], subject)).toBeNull(); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.ts b/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.ts index 71e4b6e9173b..7a0bd52f95af 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.ts @@ -33,10 +33,6 @@ import { TypeRelationQueryColumn, } from 'core-app/features/work-packages/components/wp-query/query-column'; import { WorkPackageTable } from 'core-app/features/work-packages/components/wp-fast-table/wp-fast-table'; -import { - QUERY_SORT_BY_ASC, - QUERY_SORT_BY_DESC, QuerySortByDirection, -} from 'core-app/features/hal/resources/query-sort-by-resource'; import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service'; import { WorkPackageViewSortByService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-sort-by.service'; import { WorkPackageViewGroupByService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-group-by.service'; @@ -46,6 +42,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { TableUiWork } from 'core-app/features/work-packages/components/wp-fast-table/table-ui-work'; import { onDestroySafely, runCleanup } from 'core-app/shared/helpers/angular/owned-ui-cleanup'; import { WorkPackageViewBaselineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-baseline.service'; +import { primarySortDirection } from './primary-sort-direction'; @Component({ // eslint-disable-next-line @angular-eslint/component-selector @@ -127,7 +124,7 @@ export class SortHeaderDirective implements AfterViewInit { baselineIncompatible = false; - private currentSortDirection:QuerySortByDirection|null; + private currentSortDirection:'asc'|'desc'|null; constructor() { onDestroySafely(this.destroyRef, () => { @@ -165,18 +162,12 @@ export class SortHeaderDirective implements AfterViewInit { filter(() => this.table === table && !table.destroyed && !this.destroyRef.destroyed), ) .subscribe(() => { - const latestSortElement = this.wpTableSortBy.current[0]; - - if (this.headerColumn.href !== latestSortElement?.column.href) { - this.currentSortDirection = null; - } else { - this.currentSortDirection = latestSortElement.direction; - } + this.currentSortDirection = primarySortDirection(this.wpTableSortBy.current, this.headerColumn); this.setActiveColumnClass(); this.sortable = this.wpTableSortBy.isSortable(this.headerColumn); - this.directionClass = this.getDirectionClass(); + this.directionClass = this.currentSortDirection ?? ''; this.cdRef.detectChanges(); })); @@ -278,21 +269,6 @@ export class SortHeaderDirective implements AfterViewInit { } } - private getDirectionClass():string { - if (!this.currentSortDirection) { - return ''; - } - - switch (this.currentSortDirection.href) { - case QUERY_SORT_BY_ASC: - return 'asc'; - case QUERY_SORT_BY_DESC: - return 'desc'; - default: - return ''; - } - } - setActiveColumnClass() { if (!this.table || this.table.destroyed || this.destroyRef.destroyed) return; if (this.currentSortDirection) { diff --git a/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.html b/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.html index d91279cbf41b..4cfbe269023d 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.html +++ b/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.html @@ -5,6 +5,10 @@ class="keyboard-accessible-list generic-table work-package-table" data-controller="table-highlighting" [class.op-table-baseline]="baselineEnabled" + [attr.role]="configuration.keyboardNavigationEnabled ? 'treegrid' : null" + [attr.aria-multiselectable]="configuration.keyboardNavigationEnabled ? 'true' : null" + [attr.aria-label]="configuration.keyboardNavigationEnabled ? text.tableName : null" + [attr.aria-describedby]="configuration.keyboardNavigationEnabled && sortDescription ? sortDescriptionId : null" > @if (configuration.dragAndDropEnabled) { @@ -24,15 +28,24 @@ } - - {{text.tableSummary}} - - {{text.tableSummaryHints}} - - - + @if (configuration.keyboardNavigationEnabled) { + + {{sortDescription}} + + } @else { + + {{text.tableSummary}} + + {{text.tableSummaryHints}} + + } + + @if (configuration.dragAndDropEnabled) { - +
{{text.sorting}} @if (manualSortEnabled) { @@ -42,7 +55,9 @@ } @if (baselineEnabled) { - +
@@ -53,7 +68,9 @@ [attr.aria-label]="column.name" [ngClass]="{'-max': column.id === 'subject', '-min-200': ['estimatedTime', 'remainingTime'].includes(column.id) }" class="wp-table--table-header" - [class.wp-table--navigable-cell]="configuration.keyboardNavigationEnabled"> + [class.wp-table--navigable-cell]="configuration.keyboardNavigationEnabled" + [attr.role]="configuration.keyboardNavigationEnabled ? 'columnheader' : null" + [attr.aria-sort]="ariaSort(column)"> + [class.wp-table--navigable-cell]="configuration.keyboardNavigationEnabled" + [attr.role]="configuration.keyboardNavigationEnabled ? 'columnheader' : null" + [attr.aria-label]="configuration.keyboardNavigationEnabled ? (configuration.columnMenuEnabled ? text.configurationHeader : text.actionsHeader) : null"> @if (configuration.columnMenuEnabled) {
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 075e6dc86194..99b5fc49c9e5 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 @@ -28,14 +28,16 @@ import { Component, Injector, NO_ERRORS_SCHEMA, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { fireEvent, waitFor } from '@testing-library/dom'; +import { fireEvent, screen, waitFor, within } from '@testing-library/dom'; import { config, of } from 'rxjs'; import { ActionsService } from 'core-app/core/state/actions/actions.service'; import { States } from 'core-app/core/states/states.service'; +import { QUERY_SORT_BY_ASC, QUERY_SORT_BY_DESC, QuerySortByResource } from 'core-app/features/hal/resources/query-sort-by-resource'; import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource'; import { shareModalUpdated } from 'core-app/features/work-packages/components/wp-share-modal/sharing.actions'; import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space'; import { WorkPackageViewBaselineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-baseline.service'; +import { WorkPackageViewSortByService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-sort-by.service'; import { WorkPackageViewSumService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-sum.service'; import { WorkPackageViewTimelineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-timeline.service'; import { tableRefreshRequest } from 'core-app/features/work-packages/routing/wp-view-base/work-packages-view.actions'; @@ -378,3 +380,272 @@ describe('WorkPackagesTableComponent physical columns', () => { expect(component.numTableColumns).toBe(queryColumns + 2 + 1); }); }); + +/** Shows an enabled and a capability-off table on one page. */ +@Component({ + template: '@for (configuration of configurations; track $index) { }', + standalone: false, +}) +class TwoTablesStandInComponent { + private readonly disabled:Record = { ...new ListViewStandInComponent().configuration }; + + configurations = [{ ...this.disabled, keyboardNavigationEnabled: true }, this.disabled]; +} + +describe('WorkPackagesTableComponent treegrid structure', () => { + const translations = { + 'js.label_work_package_plural': 'Work packages', + 'js.label_sorting': 'Sorting', + 'js.toolbar.settings.configure_view': 'Configure view', + 'js.work_packages.table.summary': 'Table with rows of work package and columns of work package attributes.', + 'js.work_packages.table.grid_actions_header': 'Actions', + 'js.work_packages.table.grid_baseline_header': 'Comparison status', + 'js.work_packages.table.sorted_by': 'Sorted by %{criteria}.', + 'js.work_packages.table.sort_criterion_ascending': '%{column} ascending', + 'js.work_packages.table.sort_criterion_descending': '%{column} descending', + 'js.work_packages.table.sorted_manually': 'Sorted manually.', + }; + const enabled = { keyboardNavigationEnabled: true }; + let baselineActive:boolean; + let querySpace:IsolatedQuerySpace; + let states:States; + let fixture:ComponentFixture; + + beforeEach(async () => { + baselineActive = false; + await TestBed.configureTestingModule({ + declarations: [WorkPackagesTableComponent, ListViewStandInComponent, TwoTablesStandInComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + ...harnessProviders(new FakeDragAndDropService(), { workPackages: [], translations }), + WorkPackageViewSumService, + { provide: WorkPackageViewBaselineService, useValue: { isActive: () => baselineActive, isChanged: () => false, live$: () => of(null) } }, + ], + }).compileComponents(); + + const injector = TestBed.inject(Injector); + injector.get(DisplayFieldService).addFieldType(TextDisplayField, 'text', ['String']); + querySpace = TestBed.inject(IsolatedQuerySpace); + states = TestBed.inject(States); + const query = buildQuery(['id', 'subject'], null, false, false); + querySpace.query.putValue(query); + querySpace.groups.putValue([]); + initializeViewServices(injector, query); + TestBed.inject(WorkPackageViewSumService).initialize(query); + putResults([]); + }); + + afterEach(() => { + (fixture?.nativeElement as HTMLElement|undefined)?.remove(); + }); + + 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); + } + + function tables():WorkPackagesTableComponent[] { + return fixture.debugElement.queryAll(By.directive(WorkPackagesTableComponent)) + .map((debug) => debug.componentInstance as WorkPackagesTableComponent); + } + + function registerTables() { + fixture.debugElement.queryAll(By.directive(WorkPackagesTableComponent)).forEach((debug) => { + const component = debug.componentInstance as WorkPackagesTableComponent; + if (component.workPackageTable) return; + const timelineBody = document.createElement('div'); + (debug.nativeElement as HTMLElement).querySelector('.work-packages-tabletimeline--timeline-side')!.appendChild(timelineBody); + component.registerTimeline({} as WorkPackageTimelineTableController, timelineBody); + }); + } + + async function render(fixtures:WorkPackageFixture[]) { + querySpace.tableRendered.clear(); + putResults(fixtures); + querySpace.initialized.putValue(null); + await waitFor(() => expect(querySpace.tableRendered.value?.filter(({ workPackageId }) => workPackageId !== null)) + .toHaveLength(fixtures.length)); + } + + async function mount(configuration:Record, fixtures:WorkPackageFixture[] = [{ id: '1' }, { id: '2' }]) { + const listView = TestBed.createComponent(ListViewStandInComponent); + fixture = listView; + listView.componentInstance.configuration = { ...listView.componentInstance.configuration, ...configuration }; + document.body.appendChild(listView.nativeElement as HTMLElement); + listView.detectChanges(); + registerTables(); + await render(fixtures); + return tables()[0]; + } + + async function replaceTable(configuration:Record) { + const listView = fixture as ComponentFixture; + listView.componentInstance.showTable.set(false); + listView.detectChanges(); + listView.componentInstance.configuration = { ...new ListViewStandInComponent().configuration, ...configuration }; + listView.componentInstance.showTable.set(true); + listView.detectChanges(); + registerTables(); + await render([{ id: '1' }]); + } + + function sortBy(...criteria:[string, string][]) { + TestBed.inject(WorkPackageViewSortByService).update(criteria.map(([column, direction]) => ({ + column: { id: column, name: column, href: `/api/v3/queries/columns/${column}` }, + direction: { href: direction }, + }) as unknown as QuerySortByResource)); + } + + function headerRow():HTMLElement { + return screen.getByRole('treegrid').querySelector('thead tr')!; + } + + function referencedIds(root:ParentNode):string[] { + return Array.from(root.querySelectorAll('[aria-describedby], [aria-labelledby]')) + .flatMap((element) => [element.getAttribute('aria-describedby'), element.getAttribute('aria-labelledby')]) + .flatMap((value) => value?.split(/\s+/) ?? []); + } + + describe('naming', () => { + it('names the enabled table and drops the legacy caption', async () => { + await mount(enabled); + const grid = screen.getByRole('treegrid', { name: 'Work packages' }); + + expect(grid).toHaveAttribute('aria-multiselectable', 'true'); + expect(within(grid).queryByText(translations['js.work_packages.table.summary'])).not.toBeInTheDocument(); + }); + + it('keeps the legacy caption and native table on a capability-off table', async () => { + await mount({}); + const table = screen.getByRole('table'); + + expect(table).not.toHaveAttribute('role'); + expect(table).not.toHaveAttribute('aria-multiselectable'); + expect(table).not.toHaveAttribute('aria-label'); + expect(table).not.toHaveAttribute('aria-describedby'); + expect(within(table).getByText(translations['js.work_packages.table.summary'])).toBeInTheDocument(); + expect(table.querySelector('#wp-table-sort-summary')).toBeInTheDocument(); + expect(table.querySelector('thead')).not.toHaveAttribute('role'); + expect(table.querySelector('thead tr')).not.toHaveAttribute('role'); + expect(table.querySelector('thead tr')).not.toHaveAttribute('aria-level'); + table.querySelectorAll('thead th').forEach((header) => { + expect(header).not.toHaveAttribute('role'); + expect(header).not.toHaveAttribute('aria-sort'); + }); + }); + }); + + describe('header structure', () => { + it('exposes a level 1 header row inside a rowgroup', async () => { + await mount(enabled); + + expect(headerRow().parentElement).toHaveAttribute('role', 'rowgroup'); + expect(headerRow()).toHaveAttribute('role', 'row'); + expect(headerRow()).toHaveAttribute('aria-level', '1'); + expect(headerRow()).not.toHaveAttribute('aria-selected'); + expect(headerRow()).not.toHaveAttribute('aria-expanded'); + }); + + it('names the drag, baseline, query and actions column headers', async () => { + baselineActive = true; + await mount({ ...enabled, dragAndDropEnabled: true, actionsColumnEnabled: true }); + const headers = within(headerRow()).getAllByRole('columnheader'); + + expect(headers).toHaveLength(5); + ['Sorting', 'Comparison status', 'id', 'subject', 'Actions'].forEach((name, index) => { + expect(headers[index]).toHaveAccessibleName(name); + }); + }); + + it('names the trailing header after the configuration control', async () => { + await mount({ ...enabled, columnMenuEnabled: true }); + + expect(within(headerRow()).getAllByRole('columnheader').at(-1)).toHaveAccessibleName('Configure view'); + }); + }); + + describe('sorting', () => { + function sortStates():(string|null)[] { + return within(headerRow()).getAllByRole('columnheader').map((header) => header.getAttribute('aria-sort')); + } + + it('marks only the primary criterion and describes all criteria in order', async () => { + await mount(enabled); + + sortBy(['subject', QUERY_SORT_BY_ASC], ['id', QUERY_SORT_BY_DESC]); + expect(sortStates()).toEqual([null, 'ascending']); + expect(screen.getByRole('treegrid')).toHaveAccessibleDescription('Sorted by subject ascending and id descending.'); + + sortBy(['id', QUERY_SORT_BY_DESC], ['subject', QUERY_SORT_BY_ASC]); + expect(sortStates()).toEqual(['descending', null]); + expect(screen.getByRole('treegrid')).toHaveAccessibleDescription('Sorted by id descending and subject ascending.'); + }); + + it('describes a primary criterion whose column is not displayed', async () => { + await mount(enabled); + + sortBy(['status', QUERY_SORT_BY_ASC], ['id', QUERY_SORT_BY_ASC], ['subject', QUERY_SORT_BY_DESC]); + + expect(sortStates()).toEqual([null, null]); + expect(screen.getByRole('treegrid')) + .toHaveAccessibleDescription('Sorted by status ascending, id ascending, and subject descending.'); + }); + + it('describes manual sorting', async () => { + await mount(enabled); + + sortBy(['manualSorting', QUERY_SORT_BY_ASC]); + + expect(sortStates()).toEqual([null, null]); + expect(screen.getByRole('treegrid')).toHaveAccessibleDescription('Sorted manually.'); + }); + }); + + describe('instances', () => { + it('clears treegrid semantics when a capability-off table replaces an enabled one', async () => { + const previous = await mount(enabled); + sortBy(['subject', QUERY_SORT_BY_ASC]); + const previousId = previous.workPackageTable.domId; + + await replaceTable({}); + + expect(screen.queryByRole('treegrid')).not.toBeInTheDocument(); + expect(screen.getByRole('table')).not.toHaveAttribute('role'); + expect(document.querySelector(`[id^="${previousId}"]`)).not.toBeInTheDocument(); + expect(referencedIds(document).filter((id) => id.startsWith(previousId))).toEqual([]); + }); + + it('keeps a capability-off table beside an enabled one native, with unique references', async () => { + fixture = TestBed.createComponent(TwoTablesStandInComponent); + document.body.appendChild(fixture.nativeElement as HTMLElement); + fixture.detectChanges(); + registerTables(); + await render([{ id: '1' }]); + sortBy(['subject', QUERY_SORT_BY_ASC]); + + const [grid] = screen.getAllByRole('treegrid'); + const [table] = screen.getAllByRole('table'); + await waitFor(() => [grid, table].forEach((element) => { + expect(element.querySelectorAll('tr.wp-table--row[data-work-package-id="1"]')).toHaveLength(1); + })); + expect(screen.getAllByRole('treegrid')).toHaveLength(1); + expect(grid).toHaveAccessibleDescription('Sorted by subject ascending.'); + expect(table).not.toHaveAttribute('aria-describedby'); + expect(table.querySelectorAll('thead, tbody, tr, th, td')).not.toHaveLength(0); + table.querySelectorAll('thead, tbody, tr, th, td').forEach((element) => { + expect(element).not.toHaveAttribute('role'); + expect(element).not.toHaveAttribute('aria-level'); + }); + table.querySelectorAll('thead, tbody, tr').forEach((element) => { + expect(element).not.toHaveAttribute('aria-selected'); + expect(element).not.toHaveAttribute('aria-expanded'); + expect(element).not.toHaveAttribute('aria-hidden'); + }); + + const references = referencedIds(document); + expect(references).not.toHaveLength(0); + references.forEach((id) => expect(document.querySelectorAll(`[id="${id}"]`)).toHaveLength(1)); + }); + }); +}); 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 da1e9931630d..f58bd8fd808c 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 @@ -55,6 +55,8 @@ import { States } from 'core-app/core/states/states.service'; import { renderedColumns } from 'core-app/features/work-packages/components/wp-fast-table/builders/rendered-columns'; import { QueryGroupByResource } from 'core-app/features/hal/resources/query-group-by-resource'; import { WorkPackageViewBaselineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-baseline.service'; +import { QUERY_SORT_BY_DESC } from 'core-app/features/hal/resources/query-sort-by-resource'; +import { primarySortDirection } from 'core-app/features/work-packages/components/wp-table/sort-header/primary-sort-direction'; export interface WorkPackageFocusContext { /** Work package that was focused */ @@ -122,8 +124,12 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O noResults:{ title:string; description:string }; limitedResults:(count:number, total:number) => string; sorting:string; + tableName:string; tableSummary:string; tableSummaryHints:string; + actionsHeader:string; + baselineHeader:string; + configurationHeader:string; }; public results:WorkPackageCollectionResource; @@ -144,6 +150,8 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O public limitedResults = false; + public sortDescription = ''; + // We need to sync certain height difference to the timeline // depending on whether inline create or sums rows are being shown public inlineCreateVisible = false; @@ -157,19 +165,23 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O this.locale = I18n.locale; this.text = { - cancel: I18n.t('js.button_cancel'), + cancel: this.I18n.t('js.button_cancel'), noResults: { - title: I18n.t('js.work_packages.no_results.title'), - description: I18n.t('js.work_packages.no_results.description'), + title: this.I18n.t('js.work_packages.no_results.title'), + description: this.I18n.t('js.work_packages.no_results.description'), }, - limitedResults: (count:number, total:number) => I18n.t('js.work_packages.limited_results', { count, total }), - sorting: I18n.t('js.label_sorting'), - tableSummary: I18n.t('js.work_packages.table.summary'), + limitedResults: (count:number, total:number) => this.I18n.t('js.work_packages.limited_results', { count, total }), + sorting: this.I18n.t('js.label_sorting'), + tableName: this.I18n.t('js.label_work_package_plural'), + tableSummary: this.I18n.t('js.work_packages.table.summary'), tableSummaryHints: [ - I18n.t('js.work_packages.table.text_inline_edit'), - I18n.t('js.work_packages.table.text_select_hint'), - I18n.t('js.work_packages.table.text_sort_hint'), + this.I18n.t('js.work_packages.table.text_inline_edit'), + this.I18n.t('js.work_packages.table.text_select_hint'), + this.I18n.t('js.work_packages.table.text_sort_hint'), ].join(' '), + actionsHeader: this.I18n.t('js.work_packages.table.grid_actions_header'), + baselineHeader: this.I18n.t('js.work_packages.table.grid_baseline_header'), + configurationHeader: this.I18n.t('js.toolbar.settings.configure_view'), }; const statesCombined = combineLatest([ @@ -201,6 +213,7 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O this.limitedResults = this.manualSortEnabled && results.total > results.count; if (this.configuration.keyboardNavigationEnabled) { + this.sortDescription = this.describeSort(); const rendered = renderedColumns(this.configuration, this.baselineEnabled); this.trailingColumnRendered = rendered.trailing; this.numTableColumns = rendered.count(this.columns.length); @@ -287,6 +300,48 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O return this.configuration.isEmbedded; } + public get sortDescriptionId():string|null { + return this.workPackageTable ? `${this.workPackageTable.domId}-sort` : null; + } + + public ariaSort(column:QueryColumn):'ascending'|'descending'|null { + if (!this.configuration.keyboardNavigationEnabled) { + return null; + } + + switch (primarySortDirection(this.wpTableSortBy.current, column)) { + case 'asc': + return 'ascending'; + case 'desc': + return 'descending'; + default: + return null; + } + } + + private describeSort():string { + const sortBy = this.wpTableSortBy.current; + + if (this.wpTableSortBy.isManualSortingMode) { + return this.I18n.t('js.work_packages.table.sorted_manually'); + } + + if (sortBy.length === 0) { + return ''; + } + + const criteria = sortBy.map(({ column, direction }) => this.I18n.t( + direction.href === QUERY_SORT_BY_DESC + ? 'js.work_packages.table.sort_criterion_descending' + : 'js.work_packages.table.sort_criterion_ascending', + { column: column.name }, + )); + + return this.I18n.t('js.work_packages.table.sorted_by', { + criteria: new Intl.ListFormat(this.I18n.locale, { type: 'conjunction' }).format(criteria), + }); + } + private getTableAndTimelineElement():[HTMLElement, HTMLElement] { const tableSide = this.element.querySelector('.work-packages-tabletimeline--table-side'); const timelineSide = this.element.querySelector('.work-packages-tabletimeline--timeline-side'); From 6df6b70dc760bd78308b5c574934b9f6aedf6951 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 24 Oct 2026 01:58:59 +0100 Subject: [PATCH 08/13] [DREAM-885] Expose empty and inline-create rows Exposes the no-results message, the Create/reference rows and the active inline-create row on keyboard-enabled tables as level 1 structural rows of gridcells, without selection or expansion state. The inline-create builder paints the active row when building and refreshing it, and the inline-create section reconciles its rowgroup exposure after every row or template change, so it stays hidden while permissions render nothing and during the reset gap. Opening the reference row now runs change detection so the row exists before reconciliation. Capability-off tables keep their native rows. https://community.openproject.org/wp/DREAM-885 --- .../wp-fast-table/testing/table-harness.ts | 13 +- .../inline-create-row-builder.ts | 16 +- .../wp-inline-create.component.html | 6 + .../wp-inline-create.component.ts | 22 +- .../wp-table/wp-table.component.html | 10 +- .../wp-table/wp-table.component.spec.ts | 270 +++++++++++++++++- 6 files changed, 325 insertions(+), 12 deletions(-) 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 4d6824880e0a..1a5037338748 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 @@ -449,17 +449,24 @@ export class FakeDragAndDropService { } } -/** Stands in for the Angular editing portal: a plain input driven by the production field handler. */ +/** Stands in for the Angular editing portal: a plain input, labelled with its field and error messages like the portal, driven by the production field handler. */ class FakeEditingPortalService { create(container:HTMLElement, injector:Injector, form:EditForm, schema:IFieldSchema, fieldName:string, errors:string[], destroyRef?:DestroyRef):Promise { const wrapper = document.createElement('div'); wrapper.className = activeFieldContainerClassName; + const label = document.createElement('label'); + label.className = 'sr-only'; const input = document.createElement('input'); input.className = activeFieldClassName; - wrapper.appendChild(input); + wrapper.append(label, input); container.appendChild(wrapper); const handler = new HalResourceEditFieldHandler(injector, form, fieldName, schema, container, injector.get(PathHelperService), errors, destroyRef); - const markErrors = () => wrapper.classList.toggle('-error', handler.isErrorenous); + input.id = handler.htmlId; + label.htmlFor = handler.htmlId; + const markErrors = () => { + wrapper.classList.toggle('-error', handler.isErrorenous); + label.textContent = `${handler.fieldLabel} ${handler.errorMessageOnLabel()}`.trim(); + }; markErrors(); handler.errorsChanged$.subscribe(markErrors); let unregister:() => void = () => undefined; diff --git a/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts index 34273bbf96da..7156a5ad1ba1 100644 --- a/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts @@ -33,6 +33,8 @@ import { WorkPackageViewSelectionService } from 'core-app/features/work-packages import { WorkPackageViewColumnsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-columns.service'; import { QueryColumn } from 'core-app/features/work-packages/components/wp-query/query-column'; import { tdClassName } from 'core-app/features/work-packages/components/wp-fast-table/builders/cell-builder'; +import { keyboardNavigationEnabled } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; +import { paintRowRole } from 'core-app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics'; import { internalContextMenuColumn } from 'core-app/features/work-packages/components/wp-fast-table/builders/internal-sort-columns'; import { EditForm } from 'core-app/shared/components/fields/edit/edit-form/edit-form'; import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; @@ -87,7 +89,11 @@ export class InlineCreateRowBuilder extends SingleRowBuilder { // Get any existing edit state for this work package const [row, hidden] = this.buildEmpty(workPackage); - return [row, hidden]; + return [this.paintStructuralRow(row), hidden]; + } + + public refreshRow(workPackage:WorkPackageResource, row:HTMLTableRowElement):HTMLTableRowElement { + return this.paintStructuralRow(super.refreshRow(workPackage, row)); } /** @@ -110,6 +116,14 @@ export class InlineCreateRowBuilder extends SingleRowBuilder { return tr; } + private paintStructuralRow(row:HTMLTableRowElement):HTMLTableRowElement { + if (keyboardNavigationEnabled(this.workPackageTable.configuration)) { + paintRowRole(row, 1); + } + + return row; + } + protected buildCancelButton() { const td = document.createElement('td'); td.classList.add(tdClassName, 'wp-table--cancel-create-td'); diff --git a/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.html b/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.html index b83600f51d37..ad4bdfbae2dd 100644 --- a/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.html +++ b/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.html @@ -2,10 +2,13 @@ @if (mode === 'inactive') { @if (canAdd) {