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-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-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/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/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..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 @@ -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,121 @@ 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, 'total'); + + 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); + }); +}); + +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/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..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 @@ -268,3 +268,132 @@ 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; + + 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); + }); +}); + +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/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/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)} `; } 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/relation-cell-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relation-cell-builder.ts index 9e9f0cfc5d73..f501ea3c6354 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relation-cell-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relation-cell-builder.ts @@ -36,6 +36,7 @@ import { WorkPackageRelationsService } from '../../wp-relations/wp-relations.ser import { QueryColumn, queryColumnTypes } from '../../wp-query/query-column'; import { tdClassName } from './cell-builder'; import { keyboardNavigationEnabled, navigableCellClassName } from 'core-app/features/work-packages/components/wp-fast-table/handlers/state/keyboard/roving-tabindex'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; import { WorkPackageTableConfiguration } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration'; export const relationCellTdClassName = 'wp-table--relation-cell-td'; @@ -48,6 +49,8 @@ export class RelationCellbuilder { @LazyInject() wpTableRelationColumns:WorkPackageViewRelationColumnsService; + @LazyInject() I18n!:I18nService; + constructor(public readonly injector:Injector, private readonly configuration?:WorkPackageTableConfiguration) { } @@ -77,7 +80,7 @@ export class RelationCellbuilder { count = relations.length; } - const indicator = this.renderIndicator(); + const indicator = this.renderIndicator(workPackage, column, expanded); const badge = this.renderBadge(count); if (expanded) { @@ -92,11 +95,25 @@ export class RelationCellbuilder { return td; } - private renderIndicator() { + private renderIndicator(workPackage:WorkPackageResource, column:QueryColumn, expanded:boolean) { + if (keyboardNavigationEnabled(this.configuration)) { + const button = document.createElement('button'); + button.type = 'button'; + button.classList.add(relationCellIndicatorClassName); + button.setAttribute('tabindex', '-1'); + button.setAttribute('aria-expanded', expanded.toString()); + button.setAttribute('aria-label', this.I18n.t('js.work_packages.table.relations_button', { + column: column.name, + work_package: workPackage.formattedId, + })); + + return button; + } + const indicator = document.createElement('span'); indicator.classList.add(relationCellIndicatorClassName); indicator.setAttribute('aria-hidden', 'true'); - indicator.setAttribute('tabindex', keyboardNavigationEnabled(this.configuration) ? '-1' : '0'); + indicator.setAttribute('tabindex', '0'); return indicator; } 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..3f0532004a7a 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 @@ -25,7 +25,11 @@ // 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 { States } from 'core-app/core/states/states.service'; +import { within } from '@testing-library/dom'; import { buildTable, TableHarness } from '../../testing/table-harness'; +import { buildWorkPackage } from '../../testing/work-package-fixture'; describe('RelationsRenderPass', () => { let harness:TableHarness; @@ -63,4 +67,195 @@ 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); + }); + + describe('relation expander buttons', () => { + const translations = { 'js.work_packages.table.relations_button': 'Relations in %{column} for %{work_package}' }; + const columns = [ + 'id', + 'subject', + { id: 'follows', relationType: 'follows' }, + { id: 'blocks', relationType: 'blocks' }, + ]; + const relations = [ + { from: '1', to: '2', type: 'follows', reverseType: 'precedes' }, + { from: '1', to: '3', type: 'blocks', reverseType: 'blocked' }, + ]; + const expanders = (row:HTMLElement) => within(row).queryAllByRole('button', { name: /^Relations in / }); + + it('names each available expander and reads the expansion state', async () => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }, { id: '3' }], + relations, + columns, + translations, + configuration: { keyboardNavigationEnabled: true }, + }); + harness.expand('1', 'follows'); + + await harness.render(); + + const row = within(harness.row('1')); + expect(row.getByRole('button', { name: 'Relations in follows for #1' })).toHaveAttribute('aria-expanded', 'true'); + expect(row.getByRole('button', { name: 'Relations in blocks for #1' })).toHaveAttribute('aria-expanded', 'false'); + }); + + it('moves the expanded state when another column is expanded', async () => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }, { id: '3' }], + relations, + columns, + translations, + configuration: { keyboardNavigationEnabled: true }, + }); + harness.expand('1', 'follows'); + await harness.render(); + + const redrawn = harness.nextRender(); + harness.expand('1', 'blocks'); + await redrawn; + + const row = within(harness.row('1')); + expect(row.getByRole('button', { name: 'Relations in follows for #1' })).toHaveAttribute('aria-expanded', 'false'); + expect(row.getByRole('button', { name: 'Relations in blocks for #1' })).toHaveAttribute('aria-expanded', 'true'); + }); + + it('exposes the expanded state only on the primary occurrence of a repeated work package', async () => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }, { id: '3' }], + relations: [ + { from: '1', to: '2', type: 'follows', reverseType: 'precedes' }, + { from: '2', to: '3', type: 'follows', reverseType: 'precedes' }, + ], + columns: ['id', 'subject', { id: 'follows', relationType: 'follows' }], + translations, + configuration: { keyboardNavigationEnabled: true }, + }); + harness.expand('1', 'follows'); + harness.expand('2', 'follows'); + + await harness.render(); + + const buttons = expanders(harness.row('2')); + expect(buttons).toHaveLength(1); + expect(buttons[0]).toHaveAttribute('aria-expanded', 'true'); + expect(expanders(harness.relationRow('1', '2'))).toHaveLength(0); + }); + + it('renders no button for zero counts or in relation-label cells', async () => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }], + relations: [{ from: '1', to: '2', type: 'follows', reverseType: 'precedes' }], + columns: ['id', 'subject', { id: 'follows', relationType: 'follows' }, { id: 'blocks', relationType: 'blocks' }], + translations, + configuration: { keyboardNavigationEnabled: true }, + }); + harness.expand('1', 'follows'); + + await harness.render(); + + expect(expanders(harness.row('1'))).toHaveLength(1); + expect(within(harness.row('1')).getByRole('button', { name: /^Relations in / })).toBeEmptyDOMElement(); + expect(harness.row('1').querySelector('.wp-table--relation-count')).toHaveTextContent('1'); + expect(expanders(harness.relationRow('1', '2'))).toHaveLength(0); + expect(harness.row('1')).not.toHaveAttribute('aria-expanded'); + }); + + it('leaves hierarchy expansion independent of relation expansion', async () => { + const parent = { id: '1' }; + harness = buildTable({ + workPackages: [parent, { id: '2', ancestors: [parent] }, { id: '3' }], + relations: [{ from: '1', to: '3', type: 'follows', reverseType: 'precedes' }], + columns: ['id', 'subject', { id: 'follows', relationType: 'follows' }], + showHierarchies: true, + translations, + configuration: { keyboardNavigationEnabled: true }, + }); + harness.injector.get(WorkPackageViewHierarchiesService).collapse('1'); + harness.expand('1', 'follows'); + + await harness.render(); + + expect(within(harness.row('1')).getByRole('button', { name: 'Relations in follows for #1' })).toHaveAttribute('aria-expanded', 'true'); + expect(harness.row('1')).toHaveAttribute('aria-expanded', 'false'); + }); + + it('keeps the span indicator when keyboard navigation is off', async () => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }], + relations: [{ from: '1', to: '2', type: 'follows', reverseType: 'precedes' }], + columns: ['id', 'subject', { id: 'follows', relationType: 'follows' }], + translations, + }); + + await harness.render(); + + const indicator = harness.row('1').querySelector('.wp-table--relation-indicator')!; + expect(indicator.tagName).toBe('SPAN'); + expect(indicator).toHaveAttribute('aria-hidden', 'true'); + }); + + it('redraws the expanded state once asynchronous child loading completes', async () => { + let resolveLoad!:() => void; + const loaded = new Promise((resolve) => { resolveLoad = resolve; }); + harness = buildTable({ + workPackages: [{ id: '1', children: [{ id: '3' }] }], + columns: ['id', 'subject', { id: 'children', children: true }], + loadChildren: false, + translations, + configuration: { keyboardNavigationEnabled: true }, + requireAll: async (ids) => { + await loaded; + return ids.map((id) => { + const child = buildWorkPackage({ id }); + harness.injector.get(States).workPackages.get(id).putValue(child); + return child; + }); + }, + }); + harness.expand('1', 'children'); + + await harness.render(); + + const name = 'Relations in children for #1'; + expect(within(harness.row('1')).getByRole('button', { name })).toHaveAttribute('aria-expanded', 'true'); + expect(harness.rows()).toHaveLength(1); + + const redrawn = harness.nextRender(); + resolveLoad(); + await redrawn; + + expect(harness.rows()).toHaveLength(2); + expect(within(harness.row('1')).getByRole('button', { name })).toHaveAttribute('aria-expanded', 'true'); + }); + }); }); 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/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..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,11 @@ 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', () => { const harnesses:TableHarness[] = []; @@ -73,3 +76,146 @@ 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); + }); +}); + +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 68c15664df9e..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 @@ -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'; @@ -87,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(); @@ -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); } @@ -226,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; } @@ -243,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/handlers/cell/relations-cell-handler.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/relations-cell-handler.spec.ts new file mode 100644 index 000000000000..11eba9ba8567 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/relations-cell-handler.spec.ts @@ -0,0 +1,83 @@ +//-- 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 { fireEvent, within } from '@testing-library/dom'; +import { WorkPackageViewRelationColumnsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-relation-columns.service'; +import { buildTable, TableHarness } from '../../testing/table-harness'; + +describe('RelationsCellHandler', () => { + const translations = { 'js.work_packages.table.relations_button': 'Relations in %{column} for %{work_package}' }; + let harness:TableHarness; + + const mount = async (keyboardNavigationEnabled:boolean) => { + harness = buildTable({ + workPackages: [{ id: '1' }, { id: '2' }], + relations: [{ from: '1', to: '2', type: 'follows', reverseType: 'precedes' }], + columns: ['id', 'subject', { id: 'follows', relationType: 'follows' }], + translations, + configuration: { keyboardNavigationEnabled }, + }); + await harness.render(); + const service = harness.injector.get(WorkPackageViewRelationColumnsService); + return { + setExpandFor: vi.spyOn(service, 'setExpandFor'), + collapse: vi.spyOn(service, 'collapse'), + }; + }; + + afterEach(() => harness.destroy()); + + it('toggles once on a pointer click', async () => { + const spies = await mount(true); + const button = within(harness.row('1')).getByRole('button', { name: 'Relations in follows for #1' }); + + fireEvent.click(button); + + expect(spies.setExpandFor).toHaveBeenCalledTimes(1); + expect(spies.collapse).not.toHaveBeenCalled(); + }); + + it('toggles once for Enter keydown followed by its synthesized click', async () => { + const spies = await mount(true); + const button = within(harness.row('1')).getByRole('button', { name: 'Relations in follows for #1' }); + + fireEvent.keyDown(button, { key: 'Enter' }); + fireEvent.click(button); + + expect(spies.setExpandFor).toHaveBeenCalledTimes(1); + expect(spies.collapse).not.toHaveBeenCalled(); + }); + + it('still toggles the disabled-capability span on Enter keydown', async () => { + const spies = await mount(false); + const indicator = harness.row('1').querySelector('.wp-table--relation-indicator')!; + + fireEvent.keyDown(indicator, { key: 'Enter' }); + + expect(spies.setExpandFor).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/relations-cell-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/relations-cell-handler.ts index e21d6cea7c83..85df2fbfb9c1 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/relations-cell-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/relations-cell-handler.ts @@ -56,6 +56,14 @@ export class RelationsCellHandler extends ClickOrEnterHandler implements TableEv super(); } + public handleEvent(view:TableEventComponent, evt:MouseEvent|KeyboardEvent) { + if (evt.type === 'keydown' && (evt.target as HTMLElement).tagName === 'BUTTON') { + return; + } + + super.handleEvent(view, evt); + } + protected processEvent(table:WorkPackageTable, evt:MouseEvent|KeyboardEvent):void { debugLog('Handled click on relation cell %o', evt.target); evt.preventDefault(); 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/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/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..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 @@ -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; @@ -130,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..8754d6cf0eec --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/treegrid-semantics.spec.ts @@ -0,0 +1,459 @@ +//-- 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 { 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' }; +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').lastElementChild!.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')); + }); + + 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)[] = []; + 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/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..6c330720eb65 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; } @@ -445,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; @@ -516,11 +527,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 +569,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 +589,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) => (Object.hasOwn(args, name) ? String(args[name]) : match)); +} + +function baselineStub(active:boolean) { + return { + isActive: () => active, + isChanged: (workPackage:WorkPackageResource, attribute:string) => active + && Object.hasOwn(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, 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..eb99b9325943 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 { generateId } from 'core-app/shared/helpers/dom-helpers'; 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'; @@ -48,6 +49,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; @@ -65,6 +67,8 @@ export class WorkPackageTable { @LazyInject() workPackageViewCollapsedGroupsService:WorkPackageViewCollapsedGroupsService; + public readonly domId = generateId('wp-table'); + public originalRows:string[] = []; public colspan:number; @@ -94,6 +98,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 +211,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 +246,7 @@ export class WorkPackageTable { }); if (replaced > 0) { + this.semantics?.paintWorkPackage(workPackage.id!); this.notifyRowsRefreshed(workPackage.id!); } } @@ -274,6 +288,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/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..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'; @@ -68,6 +70,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: @@ -81,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)); } /** @@ -104,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) {