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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ export default defineConfig([

// Allow more than one class definitions per file (test components)
'max-classes-per-file': 'off',
// Test harnesses expose `expect*` helpers that wrap several assertions
'vitest/expect-expect': ['error', { assertFunctionNames: ['expect', '*.expect*'] }],
},
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// See COPYRIGHT and LICENSE files for more details.
//++

import { buildTable, TableHarness } from '../../../testing/table-harness';

describe('Hierarchy ordering across query windows', () => {
let harness:TableHarness;
const parent = { id: '1' };
const child = { id: '2', ancestors: [parent] };
const other = { id: '3' };

afterEach(() => harness?.destroy());

it('inserts a parent absent from the result window before its first child', async () => {
harness = buildTable({ workPackages: [child, other], showHierarchies: true });
await harness.render();
expect(harness.rowIds()).toEqual(['1', '2', '3']);
});

it('currently defers the child when its parent is in the same window', async () => {
harness = buildTable({ workPackages: [child, other, parent], showHierarchies: true });
await harness.render();
expect(harness.rowIds()).toEqual(['3', '1', '2']);
});

it('keeps a child under an earlier parent', async () => {
harness = buildTable({ workPackages: [parent, other, child], showHierarchies: true });
await harness.render();
expect(harness.rowIds()).toEqual(['1', '2', '3']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe('accepted table drag persistence lifetime', () => {
await waitFor(() => expect(calls).toEqual(expected));
}

async function mount(kind:'default'|'group'|'hierarchy' = 'group', saved = true) {
async function mount(kind:'default'|'group'|'hierarchy' = 'group', saved = true, positions = false) {
const grouped = kind === 'group';
original = query(saved, grouped, kind === 'hierarchy');
const providers:Provider[] = [
Expand Down Expand Up @@ -178,7 +178,7 @@ describe('accepted table drag persistence lifetime', () => {
views: { post: view },
} },
];
dom = buildDom();
dom = buildDom({ productionMarkup: positions });
table = buildTable({
query: original,
builtinDragActions: true,
Expand All @@ -194,6 +194,11 @@ describe('accepted table drag persistence lifetime', () => {
],
groups: grouped ? [{ value: 'New', href: status1.href, count: 1 }, { value: 'Progress', href: status2.href, count: 2 }] : undefined,
showHierarchies: kind === 'hierarchy',
...(positions ? {
productionMarkup: true,
pagination: { offset: 1, pageSize: 50, total: 3 },
configuration: { keyboardNavigationEnabled: true },
} : {}),
});
await table.render();
table.injector.get(WorkPackageViewPaginationService).initialize(original, { elements: [], offset: 1, pageSize: 50, total: 3, count: 3 } as unknown as WorkPackageCollectionResource);
Expand Down Expand Up @@ -455,6 +460,46 @@ describe('accepted table drag persistence lifetime', () => {
checkSuccessor();
});

describe('with row positions', () => {
const positions = ['1', '2', '3', '4', '5', '6'];

it('stays withdrawn through a switch to manual sorting until a matching collection arrives', async () => {
await mount('default', true, true);
table.expectRowPositions(positions, '6');
const rebuilt = table.nextRender();
const dropping = table.drop('1', '2', 'bottom');
await expectCalls(['order']);
expect(table.rowIds()).toEqual(['2', '1', '3']);
table.expectNoRowPositions();
orderSaved.resolve('now');
await expectCalls(['order', 'query']);
table.expectNoRowPositions();
querySaved.next(original);
expect(await dropping).toBe(true);
expect(original.sortBy).toEqual([manualSort]);
await rebuilt;
expect(table.rowIds()).toEqual(['2', '1', '3']);
table.expectNoRowPositions();

await table.render(['2', '1', '3'].map((id) => ({ id, attributes: { status: id === '1' ? status1 : status2 } })));
table.expectRowPositions(positions, '6');
});

it('stays withdrawn when the saved order remains visible after query saving fails', async () => {
await mount('default', true, true);
const rebuilt = table.nextRender();
const dropping = table.drop('1', '2', 'bottom');
await expectCalls(['order']);
orderSaved.resolve('now');
await expectCalls(['order', 'query']);
querySaved.error(new Error('query failed'));
expect(await dropping).toBe(false);
await rebuilt;
expect(table.rowIds()).toEqual(['2', '1', '3']);
table.expectNoRowPositions();
});
});

it('lets the adapter report a hierarchy failure once with the captured resource', async () => {
await mount('hierarchy');
const dropping = table.drop('1', '3', 'bottom');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export class DragAndDropTransformer {
recoverIfCurrent = () => {
if (!isCurrent() || table.lastRenderPass !== renderPass || source.parentNode !== parentNode) return;
parentNode?.insertBefore(source, nextSibling?.parentNode === parentNode ? nextSibling : null);
table.rowPositions?.paint();
};
if (!stillEligible()) {
complete(false);
Expand Down Expand Up @@ -234,6 +235,7 @@ export class DragAndDropTransformer {
// Only preparation observes the staged neighbors. Persistence captures no row.
if (target) table.tbody.insertBefore(source, edge === 'top' ? target : target.nextSibling);
else table.tbody.appendChild(source);
table.rowPositions?.paint();
const preparedAction = await actionService.prepareDrop(workPackage, source);
if (!stillEligible()) {
recoverIfCurrent();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,12 @@ import type { WorkPackageTable } from '../../../wp-fast-table';
import { NavigableRow, RowKind } from './focus-position';
import { navigableCells } from './roving-tabindex';
import { expandedOf, rowKindOf } from '../../../helpers/row-expansion';
import { headerRowOf, totalsRowOf } from '../../../helpers/structural-rows';

export const headerRowKey = 'header';
export const totalsRowKey = 'totals';

/** The `thead` row of the table, when rendered. */
export function headerRowOf(table:WorkPackageTable):HTMLTableRowElement|null {
return table.tbody.closest('table')?.querySelector<HTMLTableRowElement>('thead tr') ?? null;
}

/** The totals row below the results, when rendered. */
export function totalsRowOf(table:WorkPackageTable):HTMLTableRowElement|null {
const rows = table.tbody.closest('table')?.querySelectorAll<HTMLTableRowElement>('tr.wp-table--sums-row') ?? [];
return Array.from(rows).find((row) => !table.tbody.contains(row)) ?? null;
}
export { headerRowOf, totalsRowOf };

function occurrenceRow(
table:WorkPackageTable,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// See COPYRIGHT and LICENSE files for more details.
//++

import { buildTable, TableHarness } from '../testing/table-harness';
import { groupOccurrenceKey } from '../rendered-occurrence-ledger';
import {
headerRowOf, isEmptyResultsRow, isExposedRow, isInlineCreateRow, isTotalsRow, totalsRowOf,
} from './structural-rows';

describe('structural rows', () => {
let harness:TableHarness;

afterEach(() => harness?.destroy());

it('classifies the totals row below the results but not a group sums row', async () => {
const status = { href: '/api/v3/statuses/1' };
harness = buildTable({
workPackages: [{ id: '1', attributes: { status } }],
productionMarkup: true,
groups: [{ value: 'G', href: status.href, count: 1, sums: { storyPoints: 1 } }],
});
await harness.render();
const groupSums = harness.table.ledger.byKey(groupOccurrenceKey(0, 'sums'))!.element!;
expect(isTotalsRow(harness.table, harness.totalsRow())).toBe(true);
expect(isTotalsRow(harness.table, groupSums)).toBe(false);
expect(totalsRowOf(harness.table)).toBe(harness.totalsRow());
expect(headerRowOf(harness.table)).toBe(harness.headerRow());
});

it('classifies the create and empty-results rows by their sections', async () => {
harness = buildTable({ workPackages: [], productionMarkup: true });
await harness.render();
const createRow = harness.table.tbody.closest('table')!.querySelector<HTMLTableRowElement>('tbody[wpInlineCreate] tr')!;
const empty = document.createElement('tbody');
empty.className = 'work-package--empty-tbody';
const emptyRow = empty.insertRow();
harness.tbody.before(empty);
expect(isInlineCreateRow(createRow)).toBe(true);
expect(isInlineCreateRow(harness.totalsRow())).toBe(false);
expect(isEmptyResultsRow(emptyRow)).toBe(true);
expect(isEmptyResultsRow(createRow)).toBe(false);
});

it('treats rows hidden themselves or by their section as not exposed', async () => {
harness = buildTable({ workPackages: [{ id: '1' }], productionMarkup: true });
await harness.render();
const row = harness.row('1');
expect(isExposedRow(row)).toBe(true);
row.setAttribute('aria-hidden', 'true');
expect(isExposedRow(row)).toBe(false);
row.removeAttribute('aria-hidden');
row.hidden = true;
expect(isExposedRow(row)).toBe(false);
row.hidden = false;
harness.tbody.setAttribute('aria-hidden', 'true');
expect(isExposedRow(row)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// See COPYRIGHT and LICENSE files for more details.
//++

import type { WorkPackageTable } from '../wp-fast-table';

const totalsRowSelector = 'tr.wp-table--sums-row';

/** The `thead` row of the table, when rendered. */
export function headerRowOf(table:WorkPackageTable):HTMLTableRowElement|null {
return table.tbody.closest('table')?.querySelector<HTMLTableRowElement>('thead tr') ?? null;
}

/** The totals row below the results; group sums rows inside the results body do not qualify. */
export function isTotalsRow(table:WorkPackageTable, row:HTMLTableRowElement):boolean {
return row.matches(totalsRowSelector) && !table.tbody.contains(row);
}

/** The totals row below the results, when rendered. */
export function totalsRowOf(table:WorkPackageTable):HTMLTableRowElement|null {
const rows = table.tbody.closest('table')?.querySelectorAll<HTMLTableRowElement>(totalsRowSelector) ?? [];
return Array.from(rows).find((row) => isTotalsRow(table, row)) ?? null;
}

export function isInlineCreateRow(row:HTMLTableRowElement):boolean {
return row.parentElement?.hasAttribute('wpInlineCreate') === true;
}

export function isEmptyResultsRow(row:HTMLTableRowElement):boolean {
return row.parentElement?.classList.contains('work-package--empty-tbody') === true;
}

/** A row assistive technology can reach: neither hidden itself nor inside a hidden section. */
export function isExposedRow(row:HTMLTableRowElement):boolean {
return !row.hidden
&& row.getAttribute('aria-hidden') !== 'true'
&& row.parentElement?.getAttribute('aria-hidden') !== 'true';
}
Loading
Loading