diff --git a/app/models/work_package/hierarchy_row_positions.rb b/app/models/work_package/hierarchy_row_positions.rb
new file mode 100644
index 000000000000..dba322251661
--- /dev/null
+++ b/app/models/work_package/hierarchy_row_positions.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+#-- 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.
+#++
+
+class WorkPackage::HierarchyRowPositions
+ MAX_RESULTS = 10_000
+
+ def initialize(work_packages, user)
+ @work_packages = work_packages
+ @user = user
+ end
+
+ def count
+ positions.size
+ end
+
+ def entries_for(ids)
+ nodes = ids.map(&:to_i)
+ return unless nodes.all? { |node| positions.key?(node) }
+
+ nodes.each_with_object({}) { |node, entries| add_with_ancestors(node, entries) }
+ end
+
+ private
+
+ attr_reader :work_packages, :user
+
+ def add_with_ancestors(node, entries)
+ while node && !entries.key?(node.to_s)
+ entries[node.to_s] = { position: positions.fetch(node), parent: parents[node]&.to_s }
+ node = parents[node]
+ end
+ end
+
+ def positions
+ tree.first
+ end
+
+ def parents
+ tree.last
+ end
+
+ def tree
+ @tree ||= begin
+ ranks = work_packages.pluck(:id).each_with_index.to_h
+ parents, keys = link_ancestors(ranks)
+ [number_depth_first(parents, keys), parents]
+ end
+ end
+
+ def link_ancestors(ranks)
+ parents = {}
+ keys = ranks.dup
+ visible_ancestor_chains(ranks).each do |id, chain|
+ [nil, *chain, id].each_cons(2) { |parent, child| parents[child] = parent }
+ chain.each { |ancestor| keys[ancestor] = [keys[ancestor], ranks[id]].compact.min }
+ end
+ [parents, keys]
+ end
+
+ def number_depth_first(parents, keys)
+ children = keys.keys.sort_by { |id| keys[id] }.group_by { |id| parents[id] }
+ numbered = {}
+ stack = children.fetch(nil, []).reverse
+ while (id = stack.pop)
+ numbered[id] = numbered.size + 1
+ stack.concat(children.fetch(id, []).reverse)
+ end
+ numbered
+ end
+
+ def visible_ancestor_chains(ranks)
+ return {} if ranks.empty?
+
+ WorkPackageHierarchy
+ .joins("INNER JOIN work_packages ancestors ON ancestors.id = work_package_hierarchies.ancestor_id")
+ .where(descendant_id: visible_results)
+ .where("work_package_hierarchies.generations > 0")
+ .where(ancestors: { project_id: Project.allowed_to(user, :view_work_packages) })
+ .order(generations: :desc)
+ .pluck(:descendant_id, :ancestor_id)
+ .group_by(&:first)
+ .transform_values { |edges| edges.map(&:last) }
+ end
+
+ def visible_results
+ WorkPackage.visible(user).where(id: work_packages.unscope(:order).reselect(:id))
+ end
+end
diff --git a/app/services/api/v3/work_package_collection_from_query_service.rb b/app/services/api/v3/work_package_collection_from_query_service.rb
index 8df74d734624..bf69d904f75e 100644
--- a/app/services/api/v3/work_package_collection_from_query_service.rb
+++ b/app/services/api/v3/work_package_collection_from_query_service.rb
@@ -66,7 +66,8 @@ def results_to_representer(params)
params:,
project: query.project,
groups: generate_groups,
- sums: generate_total_sums)
+ sums: generate_total_sums,
+ row_positions: generate_row_positions(results_scope))
end
attr_accessor :query,
@@ -104,6 +105,13 @@ def generate_groups
end
end
+ def generate_row_positions(results_scope)
+ return unless query.show_hierarchies
+ return if results_scope.count > ::WorkPackage::HierarchyRowPositions::MAX_RESULTS
+
+ ::WorkPackage::HierarchyRowPositions.new(results_scope, current_user)
+ end
+
def generate_total_sums
return unless query.display_sums?
@@ -138,7 +146,7 @@ def column_name(column)
end
end
- def collection_representer(work_packages, params:, project:, groups:, sums:)
+ def collection_representer(work_packages, params:, project:, groups:, sums:, row_positions:)
resulting_params = calculate_resulting_params(params)
if resulting_params[:select]
@@ -158,6 +166,7 @@ def collection_representer(work_packages, params:, project:, groups:, sums:)
per_page: resulting_params[:pageSize],
groups:,
total_sums: sums,
+ row_positions:,
embed_schemas: true,
current_user:,
timestamps: query.timestamps,
diff --git a/docs/api/apiv3/components/schemas/work_packages_model.yml b/docs/api/apiv3/components/schemas/work_packages_model.yml
index 75690cc77019..4793ddba0e07 100644
--- a/docs/api/apiv3/components/schemas/work_packages_model.yml
+++ b/docs/api/apiv3/components/schemas/work_packages_model.yml
@@ -7,6 +7,38 @@ allOf:
- _links
- _embedded
properties:
+ rowCount:
+ type: integer
+ minimum: 0
+ readOnly: true
+ description: |-
+ Number of table rows the whole collection renders in hierarchy mode: every element across all pages plus
+ every visible ancestor shown for context, each counted once.
+
+ Only present when `showHierarchies` is set and the collection has at most 10,000 elements.
+ rowPositions:
+ type: object
+ readOnly: true
+ description: |-
+ Row of each element on this page, and of each visible ancestor those elements are shown under, in the
+ collection's hierarchy order. Rows are ordered depth first; sibling subtrees follow the earliest position in
+ the collection's sort order of any element they contain. Keys are work package ids. `position` counts from
+ 1 across all pages; `parent` is the id of the nearest visible ancestor, or `null` for a root.
+
+ Present whenever `rowCount` is.
+ additionalProperties:
+ type: object
+ required:
+ - position
+ - parent
+ properties:
+ position:
+ type: integer
+ minimum: 1
+ parent:
+ type:
+ - "string"
+ - "null"
_links:
type: object
required:
diff --git a/frontend/src/app/features/hal/resources/wp-collection-resource.ts b/frontend/src/app/features/hal/resources/wp-collection-resource.ts
index 7da57c399419..099126d74d4d 100644
--- a/frontend/src/app/features/hal/resources/wp-collection-resource.ts
+++ b/frontend/src/app/features/hal/resources/wp-collection-resource.ts
@@ -50,11 +50,20 @@ export class WorkPackageCollectionResource extends CollectionResource;
+ public rowCount?:number;
+
+ public rowPositions?:Record;
+
public sumsSchema?:SchemaResource;
public representations:HalResource[];
}
+export interface RowPositionEntry {
+ position:number;
+ parent:string|null;
+}
+
/**
* A reference to a group object as returned from the API.
* Augmented with state information such as collapsed state.
diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-order.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-order.spec.ts
index 45e64fb5cd99..b38f735f67a2 100644
--- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-order.spec.ts
+++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-order.spec.ts
@@ -26,6 +26,7 @@
//++
import { buildTable, TableHarness } from '../../../testing/table-harness';
+import { hierarchyOrder } from './hierarchy-order';
describe('Hierarchy ordering across query windows', () => {
let harness:TableHarness;
@@ -41,10 +42,10 @@ describe('Hierarchy ordering across query windows', () => {
expect(harness.rowIds()).toEqual(['1', '2', '3']);
});
- it('currently defers the child when its parent is in the same window', async () => {
+ it('renders a parent before a child that precedes it in the results', async () => {
harness = buildTable({ workPackages: [child, other, parent], showHierarchies: true });
await harness.render();
- expect(harness.rowIds()).toEqual(['3', '1', '2']);
+ expect(harness.rowIds()).toEqual(['1', '2', '3']);
});
it('keeps a child under an earlier parent', async () => {
@@ -53,3 +54,35 @@ describe('Hierarchy ordering across query windows', () => {
expect(harness.rowIds()).toEqual(['1', '2', '3']);
});
});
+
+describe('hierarchyOrder', () => {
+ const node = (id:string, ...ancestorIds:string[]) => ({ id, ancestorIds });
+
+ it.each([
+ ['flat results', [node('a'), node('b')], ['a', 'b']],
+ ['a child before its parent', [node('c', 'p'), node('o'), node('p')], ['p', 'c', 'o']],
+ ['a contextual parent', [node('c', 'p'), node('o')], ['p', 'c', 'o']],
+ ['nested contextual ancestors', [node('g', 'r', 'm'), node('r2')], ['r', 'm', 'g', 'r2']],
+ ['siblings under a shared contextual parent', [node('c1', 'p'), node('o'), node('c2', 'p')], ['p', 'c1', 'c2', 'o']],
+ ['a parent ranked before its children', [node('p'), node('o'), node('c', 'p')], ['p', 'c', 'o']],
+ ])('orders %s depth first by earliest rank', (_name, results, expected) => {
+ expect(hierarchyOrder(results)).toEqual(expected);
+ });
+
+ it('follows server positions over the page rank', () => {
+ const results = [node('y'), node('x2', 'x')];
+ const positions = {
+ x: { position: 1, parent: null },
+ x2: { position: 3, parent: 'x' },
+ y: { position: 5, parent: null },
+ };
+ expect(hierarchyOrder(results, positions)).toEqual(['x', 'x2', 'y']);
+ });
+
+ it.each([
+ ['an entry is missing', { x: { position: 1, parent: null }, x2: { position: 3, parent: 'x' } }],
+ ['a parent disagrees', { x: { position: 1, parent: null }, x2: { position: 3, parent: null }, y: { position: 5, parent: null } }],
+ ])('falls back to the page rank when %s', (_name, positions) => {
+ expect(hierarchyOrder([node('y'), node('x2', 'x')], positions)).toEqual(['y', 'x', 'x2']);
+ });
+});
diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-order.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-order.ts
new file mode 100644
index 000000000000..b44412194e70
--- /dev/null
+++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/hierarchy-order.ts
@@ -0,0 +1,72 @@
+//-- copyright
+// OpenProject is an open source project management software.
+// Copyright (C) the OpenProject GmbH
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import type { RowPositionEntry } from 'core-app/features/hal/resources/wp-collection-resource';
+
+export interface HierarchyNode {
+ readonly id:string;
+ /** Visible ancestors, root first. */
+ readonly ancestorIds:readonly string[];
+}
+
+export function hierarchyOrder(results:readonly HierarchyNode[], positions?:Record):string[] {
+ const parents = new Map();
+ const keys = new Map();
+ results.forEach(({ id, ancestorIds }, rank) => {
+ [null, ...ancestorIds, id].forEach((node, index, chain) => {
+ if (node === null) return;
+ parents.set(node, chain[index - 1] ?? null);
+ keys.set(node, Math.min(keys.get(node) ?? rank, rank));
+ });
+ });
+
+ if (positions && hierarchyAgrees(results, positions)) {
+ return [...parents.keys()].sort((a, b) => positions[a].position - positions[b].position);
+ }
+
+ const children = new Map();
+ [...keys.keys()]
+ .sort((a, b) => keys.get(a)! - keys.get(b)!)
+ .forEach((id) => {
+ const parent = parents.get(id)!;
+ children.set(parent, [...(children.get(parent) ?? []), id]);
+ });
+
+ const ordered:string[] = [];
+ const stack = [...(children.get(null) ?? [])].reverse();
+ for (let id = stack.pop(); id !== undefined; id = stack.pop()) {
+ ordered.push(id);
+ stack.push(...[...(children.get(id) ?? [])].reverse());
+ }
+ return ordered;
+}
+
+/** Whether the collection's parent edges match every edge of the results' visible ancestor chains. */
+export function hierarchyAgrees(results:readonly HierarchyNode[], positions:Readonly>):boolean {
+ return results.every(({ id, ancestorIds }) => [null, ...ancestorIds, id]
+ .every((node, index, chain) => node === null || positions[node]?.parent === (chain[index - 1] ?? null)));
+}
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 c80544472981..40ea7b0b6a76 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
@@ -30,16 +30,11 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-packag
import { PrimaryRenderPass } from 'core-app/features/work-packages/components/wp-fast-table/builders/primary-render-pass';
import { States } from 'core-app/core/states/states.service';
import { WorkPackageTable } from 'core-app/features/work-packages/components/wp-fast-table/wp-fast-table';
-import type { WorkPackageTableRow } from 'core-app/features/work-packages/components/wp-fast-table/wp-table.interfaces';
-import {
- ancestorClassIdentifier,
- hierarchyGroupClass,
-} from 'core-app/features/work-packages/components/wp-fast-table/helpers/wp-table-hierarchy-helpers';
+import { ancestorClassIdentifier } from 'core-app/features/work-packages/components/wp-fast-table/helpers/wp-table-hierarchy-helpers';
import { WorkPackageViewHierarchies } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-table-hierarchies';
import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space';
import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service';
import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator';
-import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import {
ancestorOccurrenceKey,
type DraftOccurrence,
@@ -47,25 +42,19 @@ import {
wpOccurrenceKey,
} from 'core-app/features/work-packages/components/wp-fast-table/rendered-occurrence-ledger';
import { additionalHierarchyRowClassName, SingleHierarchyRowBuilder } from './single-hierarchy-row-builder';
+import { hierarchyOrder } from './hierarchy-order';
+import { describesRows } from '../../../semantics/table-row-positions';
export class HierarchyRenderPass extends PrimaryRenderPass {
@LazyInject() querySpace:IsolatedQuerySpace;
@LazyInject() states:States;
- @LazyInject() apiV3Service:ApiV3Service;
-
@LazyInject() wpTableHierarchies:WorkPackageViewHierarchiesService;
// Remember which rows were already rendered
readonly rendered:Record = {};
- // Remember additional parents inserted that are not part of the results table
- private additionalParents:Record = {};
-
- // Defer children to be rendered when their parent occurs later in the table
- private deferred:Record = {};
-
// Collapsed state
private hierarchies:WorkPackageViewHierarchies;
@@ -97,158 +86,25 @@ export class HierarchyRenderPass extends PrimaryRenderPass {
* Render the hierarchy table into the document fragment
*/
protected doRender() {
- this.workPackageTable.originalRows.forEach((wpId:string) => {
- const row:WorkPackageTableRow = this.workPackageTable.originalRowIndex[wpId];
- const workPackage:WorkPackageResource = row.object;
-
- // If we need to defer this row, skip it for now
- if (this.deferInsertion(workPackage)) {
- return;
- }
-
- if (workPackage.getAncestors().length) {
- // If we have ancestors, render it
- this.buildWithHierarchy(row);
+ const results = this.workPackageTable.originalRows.map((wpId) => this.workPackageTable.originalRowIndex[wpId].object);
+ const ancestors = new Map(results.flatMap((wp) => wp.getAncestors()).map((ancestor) => [ancestor.id!, ancestor]));
+ const source = this.querySpace.results.value;
+ const order = hierarchyOrder(
+ results.map((wp) => ({ id: wp.id!, ancestorIds: wp.getAncestors().map((ancestor) => ancestor.id!) })),
+ source && describesRows(source, this.workPackageTable.originalRows) ? source.rowPositions : undefined,
+ );
+
+ order.forEach((id) => {
+ const row = this.workPackageTable.originalRowIndex[id];
+ if (row) {
+ const [tr, hidden] = this.rowBuilder.buildEmpty(row.object);
+ this.markRendered(tr, row.object, hidden);
} else {
- // Render a work package root with no parents
- const [tr, hidden] = this.rowBuilder.buildEmpty(workPackage);
- this.markRendered(tr, workPackage, hidden);
- }
-
- // Render all potentially deferred rows
- this.renderAllDeferredChildren(workPackage);
- });
- }
-
- /**
- * If the given work package has a visible ancestor in the table, return true
- * and remember the work package until the ancestor is rendered.
- * @param workPackage
- * @returns {boolean}
- */
- public deferInsertion(workPackage:WorkPackageResource):boolean {
- const ancestors = workPackage.getAncestors();
-
- // Will only defer if at least one ancestor exists
- if (ancestors.length === 0) {
- return false;
- }
-
- // Cases for wp
- // 1. No wp.ancestors in table -> Render them immediately (defer=false)
- // 2. Parent in table -> deferred[parent] = wp
- // 3. Parent not in table BUT a ancestor in table
- // -> deferred[a ancestor] = parent
- // -> deferred[parent] = wp
- // 4. Any ancestor already rendered -> Render normally (don't defer)
- const ancestorChain = ancestors.concat([workPackage]);
- for (let i = ancestorChain.length - 2; i >= 0; --i) {
- const parent = ancestorChain[i];
-
- const inTable = this.workPackageTable.originalRowIndex[parent.id!];
- const alreadyRendered = this.rendered[parent.id!];
-
- if (alreadyRendered) {
- // parent is already rendered.
- // Don't defer, but render all intermediate parents below it
- return false;
- }
-
- if (inTable) {
- // Get the current elements
- let elements = this.deferred[parent.id!] || [];
- // Append to them the child and all children below
- let newElements:WorkPackageResource[] = ancestorChain.slice(i + 1, ancestorChain.length);
- newElements = newElements.map((child) => this.apiV3Service.work_packages.cache.state(child.id!).value!);
- // Append all new elements
- elements = elements.concat(newElements);
- // Remove duplicates (Regression #29652)
- const seen = new Set();
- this.deferred[parent.id!] = elements.filter((el) => {
- if (seen.has(el.id!)) { return false; }
- seen.add(el.id!);
- return true;
- });
- return true;
- }
- // Otherwise, continue the chain upwards
- }
-
- return false;
- }
-
- /**
- * Render any deferred children of the given work package. If recursive children were
- * deferred, each of them will be passed through renderCallback.
- * @param workPackage
- */
- private renderAllDeferredChildren(workPackage:WorkPackageResource) {
- const wpId = workPackage.id!;
- const deferredChildren = this.deferred[wpId] || [];
-
- // If the work package has deferred children to render,
- // run them through the callback
- deferredChildren.forEach((child:WorkPackageResource) => {
- this.insertUnderParent(this.indexedOrGiven(child), child.parent ?? workPackage);
-
- // Descend into any children the child WP might have and callback
- this.renderAllDeferredChildren(child);
- });
- }
-
- private indexedOrGiven(workPackage:WorkPackageResource):WorkPackageResource {
- return this.workPackageTable.originalRowIndex[workPackage.id!]?.object ?? workPackage;
- }
-
- private buildWithHierarchy(row:WorkPackageTableRow) {
- // Ancestor data [root, med, thisrow]
- const ancestors = row.object.getAncestors();
- const ancestorGroups:string[] = [];
-
- // Iterate ancestors
- ancestors.forEach((el:WorkPackageResource, index:number) => {
- const ancestor = this.states.workPackages.get(el.id!).getValueOr(el);
-
- // If we see the parent the first time,
- // build it as an additional row and insert it into the ancestry
- if (!this.rendered[ancestor.id!]) {
- const [ancestorRow, hidden] = this.rowBuilder.buildAncestorRow(ancestor, ancestorGroups, index);
- // Insert the ancestor row, either right here if it's a root node
- // Or below the appropriate parent
-
- if (index === 0) {
- // Special case, first ancestor => root without parent
- this.markRendered(ancestorRow, ancestor, hidden, true);
- } else {
- // This ancestor must be inserted in the last position of its root
- const parent = ancestors[index - 1];
- this.insertAtExistingHierarchy(ancestor, ancestorRow, parent, hidden, true);
- }
-
- // Remember we just added this extra ancestor row
- this.additionalParents[ancestor.id!] = ancestor;
+ const ancestor = this.states.workPackages.get(id).getValueOr(ancestors.get(id)!);
+ const [tr, hidden] = this.rowBuilder.buildAncestorRow(ancestor);
+ this.markRendered(tr, ancestor, hidden, true);
}
-
- // Push the correct ancestor groups for identifying a hierarchy group
- ancestorGroups.push(hierarchyGroupClass(ancestor.id!));
- ancestors.slice(0, index).forEach((previousAncestor) => {
- ancestorGroups.push(hierarchyGroupClass(previousAncestor.id!));
- });
});
-
- // Insert this row to parent
- const parent = ancestors.at(-1);
- this.insertUnderParent(row.object, parent!);
- }
-
- /**
- * Insert the given node as a child of the parent
- * @param workPackage
- * @param parent
- */
- private insertUnderParent(workPackage:WorkPackageResource, parent:WorkPackageResource) {
- const [tr, hidden] = this.rowBuilder.buildEmpty(workPackage);
- this.insertAtExistingHierarchy(workPackage, tr, parent, hidden, false);
}
/**
@@ -262,25 +118,6 @@ export class HierarchyRenderPass extends PrimaryRenderPass {
this.registerAppended(row, this.buildOccurrence(workPackage, hidden, isAncestor));
}
- /**
- * Append a row to the given parent hierarchy group.
- */
- private insertAtExistingHierarchy(workPackage:WorkPackageResource,
- el:HTMLTableRowElement,
- parent:WorkPackageResource,
- hidden:boolean,
- isAncestor:boolean) {
- // Either append to the hierarchy group root (= the parentID row itself)
- const hierarchyRoot = `.__hierarchy-root-${parent.id}`;
- // Or, if it has descendants, append to the LATEST of that set
- const hierarchyGroup = `.__hierarchy-group-${parent.id}`;
-
- // Insert into table
- this.spliceRow(el, `${hierarchyRoot},${hierarchyGroup}`, this.buildOccurrence(workPackage, hidden, isAncestor));
-
- this.rendered[workPackage.id!] = true;
- }
-
private occurrenceKey(workPackage:WorkPackageResource, isAncestor:boolean):OccurrenceKey {
return isAncestor ? ancestorOccurrenceKey(workPackage.id!) : wpOccurrenceKey(workPackage.id!);
}
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 c19fd1cf2240..0c82fddd91ba 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
@@ -131,9 +131,7 @@ export class SingleHierarchyRowBuilder extends SingleRowBuilder {
/**
* Append an additional ancestor row that is not yet loaded
*/
- public buildAncestorRow(ancestor:WorkPackageResource,
- ancestorGroups:string[],
- index:number):[HTMLTableRowElement, boolean] {
+ public buildAncestorRow(ancestor:WorkPackageResource):[HTMLTableRowElement, boolean] {
const workPackage = this.states.workPackages.get(ancestor.id!).value!;
const [tr, hidden] = this.buildEmpty(workPackage);
const identifier = ancestorClassIdentifier(ancestor.id!);
diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-lifetime.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-lifetime.spec.ts
index a0de2371befb..94ca165d0b88 100644
--- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-lifetime.spec.ts
+++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-lifetime.spec.ts
@@ -29,7 +29,7 @@ import { createEnvironmentInjector, EnvironmentInjector, Injector, Provider } fr
import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { waitFor } from '@testing-library/dom';
-import { of, Subject } from 'rxjs';
+import { of, Subject, throwError } from 'rxjs';
import * as Turbo from '@hotwired/turbo';
import { nextTask } from 'core-common/testing/timing';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
@@ -463,10 +463,14 @@ describe('accepted table drag persistence lifetime', () => {
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 () => {
+ it('restores positions from the refetched collection after a switch to manual sorting', async () => {
await mount('default', true, true);
table.expectRowPositions(positions, '6');
- const rebuilt = table.nextRender();
+ const current = table.querySpace.results.value!;
+ const reordered = ['2', '1', '3'].map((id) => current.elements.find((wp) => wp.id === id)!);
+ const reloaded = { ...current, elements: reordered } as WorkPackageCollectionResource;
+ const load = vi.spyOn(table.injector.get(WorkPackagesListService), 'loadQueryFromExisting')
+ .mockReturnValue(of({ results: reloaded } as QueryResource));
const dropping = table.drop('1', '2', 'bottom');
await expectCalls(['order']);
expect(table.rowIds()).toEqual(['2', '1', '3']);
@@ -477,12 +481,48 @@ describe('accepted table drag persistence lifetime', () => {
querySaved.next(original);
expect(await dropping).toBe(true);
expect(original.sortBy).toEqual([manualSort]);
- await rebuilt;
+ await waitFor(() => expect(load).toHaveBeenCalled());
+
+ const settled = table.rows();
+ await waitFor(() => table.expectRowPositions(positions, '6'));
+ expect(table.rows()).toEqual(settled);
+ expect(table.querySpace.results.value).toBe(reloaded);
+ expect(load).toHaveBeenCalledExactlyOnceWith(original, { pageSize: 50, offset: 1 }, '9');
+ });
+
+ it('stays withdrawn when the refetched collection is in another order', async () => {
+ await mount('default', true, true);
+ const current = table.querySpace.results.value!;
+ const load = vi.spyOn(table.injector.get(WorkPackagesListService), 'loadQueryFromExisting')
+ .mockReturnValue(of({ results: { ...current } } as QueryResource));
+ const dropping = table.drop('1', '2', 'bottom');
+ await expectCalls(['order']);
+ orderSaved.resolve('now');
+ await expectCalls(['order', 'query']);
+ querySaved.next(original);
+ expect(await dropping).toBe(true);
+ await waitFor(() => expect(load).toHaveBeenCalled());
+ await nextTask();
expect(table.rowIds()).toEqual(['2', '1', '3']);
+ expect(table.querySpace.results.value).toBe(current);
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 collection cannot be refetched', async () => {
+ await mount('default', true, true);
+ vi.spyOn(table.injector.get(WorkPackagesListService), 'loadQueryFromExisting')
+ .mockReturnValue(throwError(() => new Error('offline')));
+ const rebuilt = table.nextRender();
+ const dropping = table.drop('1', '2', 'bottom');
+ await expectCalls(['order']);
+ orderSaved.resolve('now');
+ await expectCalls(['order', 'query']);
+ querySaved.next(original);
+ expect(await dropping).toBe(true);
+ await rebuilt;
+ await nextTask();
+ expect(table.rowIds()).toEqual(['2', '1', '3']);
+ table.expectNoRowPositions();
});
it('stays withdrawn when the saved order remains visible after query saving fails', async () => {
diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts
index 69748a9c9a93..a7a97ff44c40 100644
--- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts
+++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts
@@ -46,6 +46,7 @@ import { collapsedGroupClass } from 'core-app/features/work-packages/components/
import { reorderById, type Edge } from 'core-common/drag-and-drop/reorder';
import { WorkPackageTable } from '../../wp-fast-table';
import { firstValueFrom } from 'rxjs';
+import isPersistedResource from 'core-app/features/hal/helpers/is-persisted-resource';
export class DragAndDropTransformer {
@LazyInject() private readonly states:States;
@@ -259,6 +260,17 @@ export class DragAndDropTransformer {
actionService.onNewOrder([...ids]);
};
const reportUiFailure = (error:unknown) => notification.handleRawError(error, workPackage);
+ const reloadResults = async () => {
+ const query = querySpace.query.value;
+ if (!isCurrent() || !query || !isPersistedResource(query) || querySpace.groups.value?.length) return;
+ const projectIdentifier = query.project?.href?.split('/').pop();
+ const { results } = await firstValueFrom(listService.loadQueryFromExisting(query, listService.getPaginationInfo(), projectIdentifier));
+ const rendered = table.originalRows;
+ if (!isCurrent() || results.elements.length !== rendered.length
+ || results.elements.some((element, index) => element.id !== rendered[index])) return;
+ querySpace.results.putValue(results);
+ table.rowPositions?.recapture();
+ };
// Acceptance begins at the first write: navigation cannot cancel later phases.
await preparedOrder.persist();
@@ -271,7 +283,13 @@ export class DragAndDropTransformer {
await preparedQuery.persist();
}
complete(true);
- recoverIfCurrent();
+ if (isCurrent()) {
+ void refreshLiveView(preparedOrder.order)
+ .then(
+ () => reloadResults().catch(() => undefined),
+ reportUiFailure,
+ );
+ }
} catch (error:unknown) {
recoverIfCurrent();
notification.handleRawError(error, workPackage);
diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.spec.ts
index 12e0e1d7582b..8399a6999d55 100644
--- a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.spec.ts
+++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.spec.ts
@@ -25,7 +25,7 @@
// See COPYRIGHT and LICENSE files for more details.
//++
-import { bodyRowPositions, draftAgrees, PositionWindow } from './body-row-positions';
+import { bodyRowPositions, draftAgrees, PositionWindow, hierarchyRowPositions } from './body-row-positions';
const groups = [
{ headerKey: 'G', sumsKey: 'G-sum', count: 4, hasSums: true },
@@ -132,3 +132,32 @@ describe('draftAgrees', () => {
expect(draftAgrees(grouped(), [structural('G'), member('X')], groups)).toBe(false);
});
});
+
+describe('hierarchyRowPositions', () => {
+ const rows = [
+ { key: 'ancestor:1', workPackageId: '1', ancestorIds: null },
+ { key: 'wp:2', workPackageId: '2', ancestorIds: ['1'] },
+ ];
+ const entries = { 1: { position: 3, parent: null }, 2: { position: 5, parent: '1' } };
+
+ it('keys the collection positions by occurrence', () => {
+ expect(hierarchyRowPositions(9, entries, rows)).toEqual({ count: 9, ordinals: new Map([['ancestor:1', 3], ['wp:2', 5]]) });
+ });
+
+ it.each([
+ ['a negative count', -1, entries],
+ ['a fractional count', 9.5, entries],
+ ['a missing entry', 9, { 1: entries[1] }],
+ ['a disagreeing parent', 9, { ...entries, 2: { position: 5, parent: null } }],
+ ['a position beyond the count', 4, entries],
+ ['positions out of rendered order', 9, { 1: { position: 5, parent: null }, 2: { position: 3, parent: '1' } }],
+ ['a zero position', 9, { 1: { position: 0, parent: null }, 2: entries[2] }],
+ ])('rejects %s', (_name, count, given) => {
+ expect(hierarchyRowPositions(count, given, rows)).toBeNull();
+ });
+
+ it('rejects a result whose ancestry disagrees above its parent', () => {
+ const nested = [rows[0], { key: 'wp:2', workPackageId: '2', ancestorIds: ['4', '1'] }];
+ expect(hierarchyRowPositions(9, entries, nested)).toBeNull();
+ });
+});
diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.ts
index d692d7a735fb..c54858a77b66 100644
--- a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.ts
+++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/body-row-positions.ts
@@ -25,6 +25,9 @@
// See COPYRIGHT and LICENSE files for more details.
//++
+import type { RowPositionEntry } from 'core-app/features/hal/resources/wp-collection-resource';
+import { hierarchyAgrees } from '../builders/modes/hierarchy/hierarchy-order';
+
export interface BodyRowPositions {
readonly count:number;
readonly ordinals:ReadonlyMap;
@@ -168,3 +171,32 @@ export function draftAgrees(body:BodyRowPositions, rows:readonly DraftRow[], gro
}
return true;
}
+
+export interface HierarchyDraftRow {
+ readonly key:string;
+ readonly workPackageId:string;
+ /** Visible ancestors of a result row, root first; `null` for a contextual ancestor, whose edges its descendants vouch for. */
+ readonly ancestorIds:readonly string[]|null;
+}
+
+export function hierarchyRowPositions(
+ count:number,
+ entries:Readonly>,
+ rows:readonly HierarchyDraftRow[],
+):BodyRowPositions|null {
+ if (!Number.isSafeInteger(count) || count < 0) return null;
+
+ const results = rows.flatMap(({ workPackageId, ancestorIds }) => (ancestorIds ? [{ id: workPackageId, ancestorIds }] : []));
+ if (!hierarchyAgrees(results, entries)) return null;
+
+ const ordinals = new Map();
+ let previous = 0;
+ for (const { key, workPackageId } of rows) {
+ if (!Object.hasOwn(entries, workPackageId)) return null;
+ const { position } = entries[workPackageId];
+ if (!Number.isSafeInteger(position) || position <= previous || position > count) return null;
+ ordinals.set(key, position);
+ previous = position;
+ }
+ return { count, ordinals };
+}
diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.spec.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.spec.ts
index 4c5d5caed86e..a7dfeea67a96 100644
--- a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.spec.ts
+++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.spec.ts
@@ -27,11 +27,16 @@
import { buildTable, TableHarness, TableHarnessOptions } from '../testing/table-harness';
import { WorkPackageViewCollapsedGroupsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service';
+import { WorkPackageViewHierarchiesService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy.service';
import { waitFor } from '@testing-library/dom';
import { nextTask } from 'core-common/testing/timing';
import { States } from 'core-app/core/states/states.service';
import { groupOccurrenceKey } from '../rendered-occurrence-ledger';
import { buildWorkPackage } from '../testing/work-package-fixture';
+import { Subject } from 'rxjs';
+import { WorkPackagesListService } from 'core-app/features/work-packages/components/wp-list/wp-list.service';
+import { WorkPackageViewOrderService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-order.service';
+import type { QueryResource } from 'core-app/features/hal/resources/query-resource';
function deferred() {
let resolve!:(value:T) => void;
@@ -133,6 +138,128 @@ describe('TableRowPositions', () => {
expect(harness.row('21')).not.toHaveAttribute('aria-rowindex');
});
+ describe('in hierarchy mode', () => {
+ const parent = { id: '1' };
+ const child = { id: '2', ancestors: [parent] };
+ const other = { id: '3' };
+ const entries = {
+ 1: { position: 1, parent: null },
+ 2: { position: 2, parent: '1' },
+ 3: { position: 3, parent: null },
+ };
+ const mountHierarchy = (options:Partial = {}) => mount({
+ workPackages: [child, other, parent],
+ showHierarchies: true,
+ pagination: { offset: 1, pageSize: 20, total: 3 },
+ rowPositions: { count: 3, entries },
+ ...options,
+ });
+
+ it('publishes the positions the collection carries', async () => {
+ await mountHierarchy();
+ harness.expectRowPositions(['1', '2', '3', '4', '5', '6'], '6');
+ });
+
+ it('keeps query-wide positions of a later page, gaps included', async () => {
+ await mountHierarchy({
+ workPackages: [{ id: '5', ancestors: [parent] }],
+ pagination: { offset: 2, pageSize: 1, total: 8 },
+ rowPositions: { count: 9, entries: { 1: { position: 1, parent: null }, 5: { position: 7, parent: '1' } } },
+ });
+ harness.expectRowPositions(['1', '2', '8', '11', '12'], '12');
+ });
+
+ it('withdraws positions without row metadata', async () => {
+ await mountHierarchy({ rowPositions: undefined });
+ harness.expectNoRowPositions();
+ });
+
+ it('withdraws positions when a rendered parent disagrees with the collection', async () => {
+ await mountHierarchy({ rowPositions: { count: 3, entries: { ...entries, 2: { position: 2, parent: null } } } });
+ harness.expectNoRowPositions();
+ });
+
+ it('withdraws positions when a rendered row has no entry', async () => {
+ await mountHierarchy({ rowPositions: { count: 3, entries: { 1: entries[1], 2: entries[2] } } });
+ harness.expectNoRowPositions();
+ });
+
+ it('renders a reorder without a new collection in its new order and withdraws positions', async () => {
+ await mountHierarchy();
+ harness.table.initialSetup(['3', '1', '2'].map((id) => harness.table.originalRowIndex[id].object));
+ await waitFor(() => expect(harness.rowIds()).toEqual(['3', '1', '2']));
+ harness.expectNoRowPositions();
+ });
+
+ it('withdraws positions when a refreshed row moves to another parent', async () => {
+ await mountHierarchy();
+ harness.injector.get(States).workPackages.get('2').putValue(buildWorkPackage({ id: '2' }));
+ await waitFor(() => harness.expectNoRowPositions());
+ });
+
+ it('withdraws positions when a refreshed row moves above its parent', async () => {
+ await mountHierarchy();
+ harness.injector.get(States).workPackages.get('2').putValue(buildWorkPackage({ id: '2', ancestors: [{ id: '4' }, parent] }));
+ await nextTask();
+ expect(harness.row('2').querySelector('.wp-table--hierarchy-span')).toHaveAttribute('data-indentation', '65px');
+ harness.expectNoRowPositions();
+ });
+
+ it('keeps positions withdrawn when the collection is recaptured after a row moved', async () => {
+ await mountHierarchy();
+ harness.injector.get(States).workPackages.get('2').putValue(buildWorkPackage({ id: '2' }));
+ await waitFor(() => harness.expectNoRowPositions());
+ harness.table.rowPositions!.recapture();
+ harness.expectNoRowPositions();
+ });
+
+ it('does not label a row that moved while the post-drop collection was loading', async () => {
+ await mountHierarchy();
+ harness.querySpace.query.value!.id = '10';
+ const current = harness.querySpace.results.value!;
+ const loaded = new Subject();
+ const load = vi.fn(() => loaded);
+ Object.assign(harness.injector.get(WorkPackagesListService), {
+ loadQueryFromExisting: load,
+ getPaginationInfo: () => ({ pageSize: 20, offset: 1 }),
+ });
+ vi.spyOn(harness.injector.get(WorkPackageViewOrderService), 'prepareMove')
+ .mockResolvedValue({ order: ['3', '1', '2'], persist: () => Promise.resolve() });
+ const rendered = harness.nextRender();
+ expect(await harness.drop('3', '1', 'top')).toBe(true);
+ await rendered;
+ await waitFor(() => expect(load).toHaveBeenCalled());
+ harness.injector.get(States).workPackages.get('2').putValue(buildWorkPackage({ id: '2' }));
+ await nextTask();
+ harness.expectNoRowPositions();
+ loaded.next({ results: {
+ ...current,
+ elements: ['3', '1', '2'].map((id) => current.elements.find((wp) => wp.id === id)!),
+ rowPositions: { 3: { position: 1, parent: null }, 1: { position: 2, parent: null }, 2: { position: 3, parent: '1' } },
+ } } as unknown as QueryResource);
+ await nextTask();
+ expect(harness.row('2').querySelector('.wp-table--hierarchy-span')).toHaveAttribute('data-indentation', '25px');
+ harness.expectNoRowPositions();
+ });
+
+ it('keeps positions when a refreshed row keeps its parent', async () => {
+ await mountHierarchy();
+ harness.injector.get(States).workPackages.get('2').putValue(buildWorkPackage({ id: '2', ancestors: [parent] }));
+ await nextTask();
+ harness.expectRowPositions(['1', '2', '3', '4', '5', '6'], '6');
+ });
+
+ it('keeps positions and the count when a parent collapses', async () => {
+ await mountHierarchy();
+ harness.injector.get(WorkPackageViewHierarchiesService).collapse('1');
+ await waitFor(() => expect(harness.row('2')).not.toBeVisible());
+ expect(harness.row('2')).toHaveAttribute('aria-rowindex', '3');
+ expect(harness.row('1')).toHaveAttribute('aria-rowindex', '2');
+ expect(harness.row('3')).toHaveAttribute('aria-rowindex', '4');
+ expect(table()).toHaveAttribute('aria-rowcount', '6');
+ });
+ });
+
it('does not activate capability-off consumers', async () => {
await mount({ configuration: { keyboardNavigationEnabled: false } });
expect(harness.table.rowPositions).toBeNull();
diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.ts
index b3ce9469d874..2bcdc2cd2ab2 100644
--- a/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.ts
+++ b/frontend/src/app/features/work-packages/components/wp-fast-table/semantics/table-row-positions.ts
@@ -31,11 +31,13 @@ import { WorkPackageViewRelationColumnsService } from 'core-app/features/work-pa
import { WorkPackageViewColumnsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-columns.service';
import { WorkPackageViewBaselineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-baseline.service';
import type { GroupObject, WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource';
+import type { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator';
+import { States } from 'core-app/core/states/states.service';
import { groupOccurrenceKey, placeholderOccurrenceKey, wpOccurrenceKey, type RenderDraft, type RenderedOccurrence } from '../rendered-occurrence-ledger';
import type { WorkPackageTable } from '../wp-fast-table';
import { isEmptyResultsRow, isExposedRow, isInlineCreateRow, isTotalsRow } from '../helpers/structural-rows';
-import { bodyRowPositions, draftAgrees, type BodyRowPositions, type PositionGroup } from './body-row-positions';
+import { bodyRowPositions, draftAgrees, hierarchyRowPositions, type BodyRowPositions, type PositionGroup } from './body-row-positions';
export interface CapturedRowPositions {
readonly source:WorkPackageCollectionResource;
@@ -47,6 +49,12 @@ const UNSCOPED_COLLECTION_HREF = /\/api\/v3\/(?:(?:projects|workspaces)\/[^/]+\/
type ProjectedRowKind = 'ledger'|'skipped'|'empty'|'totals'|'create'|'foreign';
+export function describesRows(source:WorkPackageCollectionResource, rows:readonly string[]):boolean {
+ return source.count === rows.length
+ && source.elements.length === rows.length
+ && source.elements.every((wp, index) => wp.id === rows[index]);
+}
+
function setIfChanged(element:Element, name:string, value:string|null):void {
if (value === null) {
if (element.hasAttribute(name)) element.removeAttribute(name);
@@ -64,6 +72,8 @@ export class TableRowPositions {
@LazyInject(WorkPackageViewBaselineService) private readonly baseline:WorkPackageViewBaselineService;
+ @LazyInject(States) private readonly states:States;
+
public readonly injector:Injector = this.table.injector;
private snapshot:CapturedRowPositions|null = null;
@@ -72,7 +82,9 @@ export class TableRowPositions {
public capture(draft:RenderDraft):CapturedRowPositions|null {
const source = this.table.querySpace.results.value;
- if (!source || this.withdrawnByMode() || !this.matchesRenderedRows(source)) return null;
+ if (!source || this.withdrawnByMode()) return null;
+ if (!describesRows(source, this.table.originalRows)) return null;
+ if (this.hierarchies.isEnabled) return this.captureHierarchy(source, draft);
const groups = this.table.querySpace.groups.value ?? [];
if (groups.length > 0 && !this.trustsGroupedAggregates(source, groups)) return null;
@@ -98,6 +110,24 @@ export class TableRowPositions {
return draftAgrees(body, rows, positionGroups) ? { source, body } : null;
}
+ /** Re-derives positions for the rows on screen after the results collection changed without a render. */
+ public recapture():void {
+ if (this.table.hasPendingRender) return;
+ this.revalidate();
+ this.paint();
+ }
+
+ /** Re-derives the committed positions after a row re-rendered from a newer resource, withdrawing them if its ancestry moved. */
+ public refreshed():void {
+ if (this.snapshot) this.revalidate();
+ }
+
+ /** Commits a render's positions, recapturing them if the results changed while the render was pending. */
+ public commitRendered(captured:CapturedRowPositions|null, draft:RenderDraft):void {
+ const current = this.table.querySpace.results.value;
+ this.commit(captured?.source === current ? captured : this.capture(draft));
+ }
+
public commit(snapshot:CapturedRowPositions|null):void {
this.snapshot = snapshot;
}
@@ -116,19 +146,36 @@ export class TableRowPositions {
});
}
+ private revalidate():void {
+ const draft = this.table.lastRenderPass?.draft;
+ this.commit(draft ? this.capture(draft) : null);
+ }
+
+ private captureHierarchy(source:WorkPackageCollectionResource, draft:RenderDraft):CapturedRowPositions|null {
+ const { rowCount, rowPositions } = source;
+ if (rowCount === undefined || !rowPositions) return null;
+
+ const occurrences = draft.occurrences.filter((occurrence) => occurrence.key !== placeholderOccurrenceKey());
+ if (occurrences.some((occurrence) => occurrence.workPackageId === null || occurrence.workPackage === null)) return null;
+
+ const body = hierarchyRowPositions(rowCount, rowPositions, occurrences.map((occurrence) => ({
+ key: occurrence.key,
+ workPackageId: occurrence.workPackageId!,
+ ancestorIds: occurrence.key === wpOccurrenceKey(occurrence.workPackageId!) ? this.renderedAncestorIds(occurrence.workPackage!) : null,
+ })));
+ return body ? { source, body } : null;
+ }
+
+ /** Rows re-render from the work package cache, so the cached resource describes the row on screen, not the draft's. */
+ private renderedAncestorIds(drafted:WorkPackageResource):string[] {
+ return this.states.workPackages.get(drafted.id!).getValueOr(drafted).getAncestors().map((ancestor) => ancestor.id!);
+ }
+
private withdrawnByMode():boolean {
- if (this.hierarchies.isEnabled) return true;
return (this.columns.hasRelationColumns() || this.columns.hasChildRelationsColumn())
&& Object.keys(this.relationColumns.current).length > 0;
}
- private matchesRenderedRows(source:WorkPackageCollectionResource):boolean {
- const { originalRows } = this.table;
- return source.count === originalRows.length
- && source.elements.length === originalRows.length
- && source.elements.every((wp, index) => wp.id === originalRows[index]);
- }
-
private trustsGroupedAggregates(source:WorkPackageCollectionResource, groups:GroupObject[]):boolean {
return source.groups === groups
&& !this.baseline.isActive()
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 27fa2a4b482f..3d7f1fbb7f6f 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
@@ -46,7 +46,7 @@ import { States } from 'core-app/core/states/states.service';
import { CausedUpdatesService } from 'core-app/features/boards/board/caused-updates/caused-updates.service';
import { HAL_NEW_RESOURCE_ID } from 'core-app/features/hal/helpers/is-new-resource';
import { QueryResource } from 'core-app/features/hal/resources/query-resource';
-import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource';
+import { RowPositionEntry, WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource';
import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
import { InlineCreateRowBuilder } from 'core-app/features/work-packages/components/wp-inline-create/inline-create-row-builder';
@@ -118,6 +118,8 @@ export interface NonSortableColumnSpec { id:string, sortable:false }
export interface TableHarnessOptions {
pagination?:{ offset:number; pageSize:number; total:number };
+ /** Hierarchy row metadata of the results collection, as the API returns it. */
+ rowPositions?:{ count:number; entries:Record };
/** Self link of the results collection; defaults to the global work packages collection. */
collectionHref?:string;
workPackages:WorkPackageFixture[];
@@ -316,6 +318,8 @@ export function buildTable(options:TableHarnessOptions):TableHarness {
total: options.pagination?.total ?? resources.length,
pageSize: options.pagination?.pageSize ?? Math.max(resources.length, 1),
offset: options.pagination?.offset ?? 1,
+ rowCount: options.rowPositions?.count,
+ rowPositions: options.rowPositions?.entries,
sumsSchema: { href: sumsSchemaHref },
} as WorkPackageCollectionResource);
querySpace.initialized.putValue(null);
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 7b81a121f7c1..b4c2790fc36d 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
@@ -250,6 +250,7 @@ export class WorkPackageTable {
});
if (replaced > 0) {
+ this.rowPositions?.refreshed();
this.semantics?.paintWorkPackage(workPackage.id!);
this.notifyRowsRefreshed(workPackage.id!);
}
@@ -296,7 +297,7 @@ export class WorkPackageTable {
this.timelineBody.replaceChildren(timelinePass.timelineBody);
}
this.ledger.commit(pass.draft);
- this.rowPositions?.commit(pending.positions);
+ this.rowPositions?.commitRendered(pending.positions, pass.draft);
this.lastRenderPass = pass;
this.publishRendered();
}
diff --git a/lib/api/v3/work_packages/work_package_collection_representer.rb b/lib/api/v3/work_packages/work_package_collection_representer.rb
index d79e3077c5ed..1a620eb8fea5 100644
--- a/lib/api/v3/work_packages/work_package_collection_representer.rb
+++ b/lib/api/v3/work_packages/work_package_collection_representer.rb
@@ -39,6 +39,7 @@ def initialize(models,
groups:,
total_sums:,
current_user:,
+ row_positions: nil,
query_params: {},
project: nil,
page: nil,
@@ -48,6 +49,7 @@ def initialize(models,
query: nil)
@project = project
@total_sums = total_sums
+ @row_positions = row_positions
@embed_schemas = embed_schemas
@timestamps = timestamps
@query = query
@@ -154,6 +156,16 @@ def initialize(models,
},
render_nil: false
+ property :row_count,
+ exec_context: :decorator,
+ getter: ->(*) { row_positions.count if row_position_entries },
+ render_nil: false
+
+ property :row_positions,
+ exec_context: :decorator,
+ getter: ->(*) { row_position_entries },
+ render_nil: false
+
def current_user_allowed_to_add_work_packages?
if project
current_user.allowed_in_project?(:add_work_packages, project)
@@ -304,12 +316,19 @@ def representation_format_atom
mime_type: "application/atom+xml"
end
+ def row_position_entries
+ return @row_position_entries if defined?(@row_position_entries)
+
+ @row_position_entries = row_positions&.entries_for(represented.map(&:id))
+ end
+
def timestamps_active?
timestamps.present? && timestamps.any?(&:historic?)
end
attr_reader :project,
:total_sums,
+ :row_positions,
:embed_schemas
private
diff --git a/spec/features/work_packages/sorting/manual_sorting_spec.rb b/spec/features/work_packages/sorting/manual_sorting_spec.rb
index 99c444815f3c..e15f6b260716 100644
--- a/spec/features/work_packages/sorting/manual_sorting_spec.rb
+++ b/spec/features/work_packages/sorting/manual_sorting_spec.rb
@@ -156,6 +156,34 @@ def expect_query_order(query, expected)
wp_page.expect_no_parent
end
+ context "when reordering siblings without changing their parent" do
+ let(:work_package5) { create(:work_package, subject: "WP5", project:, parent: work_package1) }
+ let(:work_package6) { create(:work_package, subject: "WP6", project:, parent: work_package1) }
+
+ before do
+ work_package5
+ work_package6
+ wp_table.visit!
+ wp_table.expect_work_package_order(work_package1, work_package2, work_package3, work_package4,
+ work_package5, work_package6)
+ end
+
+ it "keeps the new order and its row positions once each drop settles" do
+ wp_table.drag_and_drop_work_package from: 5, to: 4
+ wp_table.expect_and_dismiss_toaster message: "Successful creation."
+ wp_table.expect_work_package_order(work_package1, work_package2, work_package3, work_package4,
+ work_package6, work_package5)
+
+ wp_table.drag_and_drop_work_package from: 5, to: 4
+ loading_indicator_saveguard
+ wp_table.expect_work_package_order(work_package1, work_package2, work_package3, work_package4,
+ work_package5, work_package6)
+ wp_table.expect_row_positions(rowcount: 8, rows: [1, 2, 3, 4, 5, 6, 7, 8])
+ wp_table.expect_work_package_order(work_package1, work_package2, work_package3, work_package4,
+ work_package5, work_package6)
+ end
+ end
+
context "when dragging an element partly out of the hierarchy" do
let(:work_package5) do
create(:work_package, subject: "WP5", project:, parent: work_package1)
@@ -376,7 +404,7 @@ def expect_query_order(query, expected)
wp_table.expect_work_package_order work_package3, work_package1, work_package2, work_package4
end
- it "exposes row positions again once a drop has reloaded the query" do
+ it "exposes row positions again once a drop settles" do
wp_table.expect_row_positions(rowcount: 6, rows: [1, 2, 3, 4, 5, 6])
wp_table.drag_and_drop_work_package from: 1, to: 3
@@ -388,9 +416,6 @@ def expect_query_order(query, expected)
wp_table.drag_and_drop_work_package from: 0, to: 2
wp_table.expect_work_package_order work_package3, work_package1, work_package2, work_package4
expect_query_order(query, [work_package3.id, work_package1.id, work_package2.id])
-
- wp_table.visit_query query
- wp_table.expect_work_package_order work_package3, work_package1, work_package2, work_package4
wp_table.expect_row_positions(rowcount: 6, rows: [1, 2, 3, 4, 5, 6])
end
diff --git a/spec/features/work_packages/table/keyboard_navigation_spec.rb b/spec/features/work_packages/table/keyboard_navigation_spec.rb
index 30febe59b2c3..cedd8b1c97c5 100644
--- a/spec/features/work_packages/table/keyboard_navigation_spec.rb
+++ b/spec/features/work_packages/table/keyboard_navigation_spec.rb
@@ -77,14 +77,14 @@
click_on "Sort ascending"
end
expect(page).to have_css(".generic-table--sort-header #subject.asc")
- wp_table.expect_work_package_order(sibling, parent, child)
+ wp_table.expect_work_package_order(parent, child, sibling)
# The column menu drops focus to body after an action until DREAM-894.
wp_table.tab_into_table
wp_table.expect_focus_on_column_header("Subject")
wp_table.press_keys(:down)
- wp_table.expect_focus_on_cell(sibling, :subject)
+ wp_table.expect_focus_on_cell(parent, :subject)
wp_table.press_keys(%i[shift tab])
wp_table.expect_focus_outside_table
diff --git a/spec/features/work_packages/table/treegrid_semantics_spec.rb b/spec/features/work_packages/table/treegrid_semantics_spec.rb
index 8c75aeba5551..f4826aa3d26e 100644
--- a/spec/features/work_packages/table/treegrid_semantics_spec.rb
+++ b/spec/features/work_packages/table/treegrid_semantics_spec.rb
@@ -255,7 +255,7 @@
it "exposes levels and expansion and removes collapsed descendants from the accessibility tree" do
wp_table.visit_query(query)
wp_table.expect_work_package_listed(parent, child, grandchild, sibling)
- wp_table.expect_no_row_positions
+ wp_table.expect_row_positions(rowcount: 6, rows: [1, 2, 3, 4, 5, 6])
expect(wp_table.treegrid).to have_selector(:row, "Alpha parent", aria: { level: 1, expanded: true, selected: false })
expect(wp_table.treegrid).to have_selector(:row, "Bravo child", aria: { level: 2, expanded: true, selected: false })
@@ -276,6 +276,7 @@
descendants = [child, grandchild].map { |wp| wp_table.treegrid.find(wp_table.row_selector(wp), visible: :hidden) }
expect(descendants.last).to match_selector(:element, aria: { hidden: true, selected: true }, visible: :hidden)
descendants.each { |row| expect_excluded_from_accessibility_tree(row) }
+ wp_table.expect_row_positions(rowcount: 6, rows: [1, 2, 3, 6])
expect_axe_clean_table
within(wp_table.treegrid.find(:row, "Alpha parent", aria: { expanded: false })) do
@@ -307,6 +308,31 @@
end
end
+ context "in hierarchy mode across pages" do
+ let!(:parent) { create(:work_package, project:, subject: "Zulu parent") }
+ let!(:child) { create(:work_package, project:, parent:, subject: "Alpha child") }
+ let!(:others) { %w[Bravo Charlie Delta].map { |name| create(:work_package, project:, subject: "#{name} other") } }
+ let!(:query) do
+ create(:query, user:, project:, column_names: %w[id subject], show_hierarchies: true,
+ sort_criteria: [%w[subject asc]])
+ end
+
+ before do
+ allow(Setting).to receive(:per_page_options).and_return "3"
+ end
+
+ it "numbers contextual ancestors once and continues the numbering on the next page" do
+ wp_table.visit_query(query)
+ wp_table.expect_work_package_order(parent, child, *others.take(2))
+ wp_table.expect_row_positions(rowcount: 7, rows: [1, 2, 3, 4, 5, 7])
+
+ within(".op-pagination--pages") { click_on "2", exact: true }
+ wp_table.expect_work_package_order(parent, others.last)
+ wp_table.expect_row_positions(rowcount: 7, rows: [1, 2, 6, 7])
+ expect_axe_clean_table
+ end
+ end
+
context "with empty results" do
let!(:doomed) { create(:work_package, project:, subject: "Doomed task") }
let!(:query) do
diff --git a/spec/lib/api/v3/work_packages/work_package_collection_representer_spec.rb b/spec/lib/api/v3/work_packages/work_package_collection_representer_spec.rb
index ed5cb8735bdd..15e0417d4e3f 100644
--- a/spec/lib/api/v3/work_packages/work_package_collection_representer_spec.rb
+++ b/spec/lib/api/v3/work_packages/work_package_collection_representer_spec.rb
@@ -45,6 +45,7 @@
let(:query_params) { {} }
let(:groups) { nil }
let(:total_sums) { nil }
+ let(:row_positions) { nil }
let(:project) { nil }
let(:page_parameter) { nil }
@@ -65,6 +66,7 @@
project:,
groups:,
total_sums:,
+ row_positions:,
page: page_parameter,
per_page: page_size_parameter,
current_user: user,
@@ -263,6 +265,11 @@
expect(collection).not_to have_json_path("totalSums")
end
+ it "does not render row positions" do
+ expect(collection).not_to have_json_path("rowCount")
+ expect(collection).not_to have_json_path("rowPositions")
+ end
+
it "has a schemas link" do
# All work packages in the collection are from the same project and have the same type
path = api_v3_paths.work_package_schemas [first_wp.project_id, first_wp.type_id]
@@ -485,6 +492,31 @@
end
end
+ context "when passing row positions" do
+ let(:row_positions) { instance_double(WorkPackage::HierarchyRowPositions, count: 7) }
+ let(:entries) do
+ work_packages.each_with_index.to_h { |wp, index| [wp.id.to_s, { position: index + 2, parent: nil }] }
+ end
+
+ before do
+ allow(row_positions).to receive(:entries_for).with(work_packages.map(&:id)).and_return(entries)
+ end
+
+ it "renders the row count and the entries for the represented work packages" do
+ expect(collection).to be_json_eql(7.to_json).at_path("rowCount")
+ expect(collection).to be_json_eql(entries.to_json).at_path("rowPositions")
+ end
+
+ context "without entries for the represented work packages" do
+ let(:entries) { nil }
+
+ it "renders neither property" do
+ expect(collection).not_to have_json_path("rowCount")
+ expect(collection).not_to have_json_path("rowPositions")
+ end
+ end
+ end
+
context "when passing schemas" do
let(:embed_schemas) { true }
diff --git a/spec/models/work_package/hierarchy_row_positions_spec.rb b/spec/models/work_package/hierarchy_row_positions_spec.rb
new file mode 100644
index 000000000000..abfc678354c2
--- /dev/null
+++ b/spec/models/work_package/hierarchy_row_positions_spec.rb
@@ -0,0 +1,157 @@
+# frozen_string_literal: true
+
+#-- 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.
+#++
+
+require "spec_helper"
+
+RSpec.describe WorkPackage::HierarchyRowPositions, with_settings: { cross_project_work_package_relations: true } do
+ shared_let(:project) { create(:project) }
+ shared_let(:hidden_project) { create(:project) }
+ shared_let(:user) { create(:user, member_with_permissions: { project => [:view_work_packages] }) }
+
+ subject(:positions) { described_class.new(ranked(*results), user) }
+
+ def ranked(*work_packages)
+ ids = work_packages.map(&:id)
+ WorkPackage
+ .where(id: ids)
+ .order(Arel.sql("array_position(ARRAY[#{ids.map(&:to_i).join(',')}]::integer[], work_packages.id)"))
+ end
+
+ def entry(position, parent = nil)
+ { position:, parent: parent&.id&.to_s }
+ end
+
+ def all_entries
+ positions.entries_for(results.map(&:id))
+ end
+
+ context "with flat roots" do
+ let(:first) { create(:work_package, project:) }
+ let(:second) { create(:work_package, project:) }
+ let(:results) { [first, second] }
+
+ it "numbers them in query order" do
+ expect(positions.count).to eq(2)
+ expect(all_entries).to eq(first.id.to_s => entry(1), second.id.to_s => entry(2))
+ end
+ end
+
+ context "with a child ranked before its parent" do
+ let(:parent) { create(:work_package, project:) }
+ let(:child) { create(:work_package, project:, parent:) }
+ let(:other) { create(:work_package, project:) }
+ let(:results) { [child, other, parent] }
+
+ it "places the parent subtree at the child's rank" do
+ expect(positions.count).to eq(3)
+ expect(all_entries).to eq(parent.id.to_s => entry(1),
+ child.id.to_s => entry(2, parent),
+ other.id.to_s => entry(3))
+ end
+ end
+
+ context "with a parent that does not match" do
+ let(:parent) { create(:work_package, project:) }
+ let(:child) { create(:work_package, project:, parent:) }
+ let(:results) { [child] }
+
+ it "counts the contextual ancestor" do
+ expect(positions.count).to eq(2)
+ expect(all_entries).to eq(parent.id.to_s => entry(1), child.id.to_s => entry(2, parent))
+ end
+ end
+
+ context "with a nested hierarchy" do
+ let(:root) { create(:work_package, project:) }
+ let(:middle) { create(:work_package, project:, parent: root) }
+ let(:grandchild) { create(:work_package, project:, parent: middle) }
+ let(:second_root) { create(:work_package, project:) }
+ let(:results) { [grandchild, second_root] }
+
+ it "inserts every contextual ancestor depth first" do
+ expect(positions.count).to eq(4)
+ expect(all_entries).to eq(root.id.to_s => entry(1),
+ middle.id.to_s => entry(2, root),
+ grandchild.id.to_s => entry(3, middle),
+ second_root.id.to_s => entry(4))
+ end
+ end
+
+ context "with results sharing a contextual ancestor" do
+ let(:parent) { create(:work_package, project:) }
+ let(:first_child) { create(:work_package, project:, parent:) }
+ let(:second_child) { create(:work_package, project:, parent:) }
+ let(:other) { create(:work_package, project:) }
+ let(:results) { [first_child, other, second_child] }
+
+ it "counts the ancestor once and keeps siblings in query order" do
+ expect(positions.count).to eq(4)
+ expect(all_entries).to eq(parent.id.to_s => entry(1),
+ first_child.id.to_s => entry(2, parent),
+ second_child.id.to_s => entry(3, parent),
+ other.id.to_s => entry(4))
+ end
+ end
+
+ context "with an invisible middle ancestor" do
+ let(:grandparent) { create(:work_package, project:) }
+ let(:hidden_parent) { create(:work_package, project: hidden_project, parent: grandparent) }
+ let(:leaf) { create(:work_package, project:, parent: hidden_parent) }
+ let(:results) { [leaf] }
+
+ it "parents the row to its nearest visible ancestor" do
+ expect(positions.count).to eq(2)
+ expect(all_entries).to eq(grandparent.id.to_s => entry(1), leaf.id.to_s => entry(2, grandparent))
+ end
+ end
+
+ context "without results" do
+ let(:results) { [] }
+
+ it "has no rows" do
+ expect(positions.count).to eq(0)
+ expect(positions.entries_for([])).to eq({})
+ end
+ end
+
+ describe "#entries_for" do
+ let(:parent) { create(:work_package, project:) }
+ let(:child) { create(:work_package, project:, parent:) }
+ let(:other) { create(:work_package, project:) }
+ let(:results) { [child, other] }
+
+ it "returns the given rows and their visible ancestors only" do
+ expect(positions.entries_for([child.id])).to eq(parent.id.to_s => entry(1), child.id.to_s => entry(2, parent))
+ end
+
+ it "returns nothing when a row is not among the numbered results" do
+ expect(positions.entries_for([child.id, parent.id + 1000])).to be_nil
+ end
+ end
+end
diff --git a/spec/requests/api/v3/work_packages/index_row_positions_resource_spec.rb b/spec/requests/api/v3/work_packages/index_row_positions_resource_spec.rb
new file mode 100644
index 000000000000..6947209418dc
--- /dev/null
+++ b/spec/requests/api/v3/work_packages/index_row_positions_resource_spec.rb
@@ -0,0 +1,170 @@
+# frozen_string_literal: true
+
+#-- 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.
+#++
+
+require "spec_helper"
+require "rack/test"
+
+RSpec.describe "GET api/v3/workspaces/:id/work_packages row positions", content_type: :json do
+ include Rack::Test::Methods
+ include API::V3::Utilities::PathHelper
+
+ shared_let(:project) { create(:project, :with_types) }
+ shared_let(:open_status) { create(:status, is_closed: false) }
+ shared_let(:closed_status) { create(:closed_status) }
+
+ shared_let(:parent) { create(:work_package, project:, subject: "C parent", status: open_status) }
+ shared_let(:child) { create(:work_package, project:, subject: "A child", parent:, status: open_status) }
+ shared_let(:other) { create(:work_package, project:, subject: "B other", status: open_status) }
+
+ current_user { create(:user, member_with_permissions: { project => [:view_work_packages] }) }
+
+ let(:params) { { showHierarchies: true, filters: "[]", sortBy: '[["subject", "asc"]]', pageSize: 20, offset: 1 } }
+ let(:body) { JSON.parse(last_response.body) }
+
+ def entry(position, parent = nil)
+ { "position" => position, "parent" => parent&.id&.to_s }
+ end
+
+ before do
+ get "#{api_v3_paths.work_packages_by_workspace(project.id)}?#{params.to_query}"
+ end
+
+ it "numbers the results in canonical hierarchy order" do
+ expect(body["rowCount"]).to eq(3)
+ expect(body["rowPositions"]).to eq(parent.id.to_s => entry(1),
+ child.id.to_s => entry(2, parent),
+ other.id.to_s => entry(3))
+ end
+
+ context "when paginated" do
+ let(:params) { super().merge(pageSize: 2) }
+
+ it "describes the page's rows and the ancestors they render" do
+ expect(body["rowCount"]).to eq(3)
+ expect(body["rowPositions"]).to eq(parent.id.to_s => entry(1),
+ child.id.to_s => entry(2, parent),
+ other.id.to_s => entry(3))
+ end
+
+ context "on the second page" do
+ let(:params) { super().merge(offset: 2) }
+
+ it "keeps the parent's position" do
+ expect(body["rowCount"]).to eq(3)
+ expect(body["rowPositions"]).to eq(parent.id.to_s => entry(1))
+ end
+ end
+ end
+
+ context "when the parent does not match the filters" do
+ let(:params) do
+ super().merge(filters: [{ subject: { operator: "~", values: ["child"] } }].to_json)
+ end
+
+ it "counts the parent as a contextual ancestor" do
+ expect(body["rowCount"]).to eq(2)
+ expect(body["rowPositions"]).to eq(parent.id.to_s => entry(1), child.id.to_s => entry(2, parent))
+ end
+ end
+
+ context "without results" do
+ let(:params) do
+ super().merge(filters: [{ subject: { operator: "~", values: ["nothing matches"] } }].to_json)
+ end
+
+ it "has no rows" do
+ expect(body["rowCount"]).to eq(0)
+ expect(body["rowPositions"]).to eq({})
+ end
+ end
+
+ context "without hierarchies" do
+ let(:params) { super().merge(showHierarchies: false) }
+
+ it "omits row positions" do
+ expect(body).not_to include("rowCount", "rowPositions")
+ end
+ end
+
+ context "when grouped" do
+ let(:params) { super().merge(groupBy: "status", showHierarchies: false) }
+
+ it "omits row positions" do
+ expect(body).not_to include("rowCount", "rowPositions")
+ end
+ end
+
+ context "with baseline timestamps", with_ee: %i[baseline_comparison] do
+ let(:params) { super().merge(timestamps: "2015-01-01T00:00:00Z,PT0S") }
+
+ it "numbers the results in canonical hierarchy order" do
+ expect(body["rowCount"]).to eq(3)
+ expect(body["rowPositions"]).to eq(parent.id.to_s => entry(1),
+ child.id.to_s => entry(2, parent),
+ other.id.to_s => entry(3))
+ end
+
+ context "when a result is no longer visible", with_settings: { cross_project_work_package_relations: true } do
+ shared_let(:hidden_project) { create(:project) }
+ let(:baseline) { Time.zone.parse("2015-01-01") }
+ let!(:hidden) do
+ create(:work_package, project:, subject: "D hidden", status: open_status, created_at: baseline - 1.day,
+ journals: { baseline - 1.day => {}, 1.day.ago => {} }).tap do |work_package|
+ work_package.update_column(:project_id, hidden_project.id)
+ work_package.journals.last.data.update_column(:project_id, hidden_project.id)
+ work_package.update!(parent:)
+ end
+ end
+ let(:params) do
+ super().merge(filters: [{ subject: { operator: "~", values: ["hidden"] } }].to_json)
+ end
+
+ before do
+ get "#{api_v3_paths.work_packages_by_workspace(project.id)}?#{params.to_query}"
+ end
+
+ it "numbers it as a root without disclosing its current parent" do
+ expect(body.dig("_embedded", "elements").pluck("id")).to eq([hidden.id])
+ expect(body["rowCount"]).to eq(1)
+ expect(body["rowPositions"]).to eq(hidden.id.to_s => entry(1))
+ end
+ end
+ end
+
+ context "with more results than the metadata covers" do
+ before do
+ stub_const("WorkPackage::HierarchyRowPositions::MAX_RESULTS", 2)
+ get "#{api_v3_paths.work_packages_by_workspace(project.id)}?#{params.to_query}"
+ end
+
+ it "omits row positions" do
+ expect(body).not_to include("rowCount", "rowPositions")
+ end
+ end
+end
diff --git a/spec/services/api/v3/work_package_collection_from_query_service_spec.rb b/spec/services/api/v3/work_package_collection_from_query_service_spec.rb
index 6bd5d5f3baa4..acd9fe70cafe 100644
--- a/spec/services/api/v3/work_package_collection_from_query_service_spec.rb
+++ b/spec/services/api/v3/work_package_collection_from_query_service_spec.rb
@@ -82,6 +82,7 @@
:project,
:groups,
:total_sums,
+ :row_positions,
:page,
:per_page,
:embed_schemas,
@@ -94,6 +95,7 @@ def initialize(work_packages,
project:,
groups:,
total_sums:,
+ row_positions:,
page:,
per_page:,
embed_schemas:,
@@ -106,6 +108,7 @@ def initialize(work_packages,
project,
groups,
total_sums,
+ row_positions,
page,
per_page,
embed_schemas,
@@ -282,6 +285,36 @@ def initialize(group,
end
end
+ describe "row_positions" do
+ context "with query.show_hierarchies being false" do
+ it "is nil" do
+ query.show_hierarchies = false
+
+ expect(subject.row_positions)
+ .to be_nil
+ end
+ end
+
+ context "with query.show_hierarchies being true" do
+ it "numbers the results for the user" do
+ query.show_hierarchies = true
+
+ expect(subject.row_positions)
+ .to be_a(WorkPackage::HierarchyRowPositions)
+ end
+ end
+
+ context "with more results than the cap" do
+ it "is nil" do
+ query.show_hierarchies = true
+ stub_const("WorkPackage::HierarchyRowPositions::MAX_RESULTS", 0)
+
+ expect(subject.row_positions)
+ .to be_nil
+ end
+ end
+ end
+
context "groups" do
context "with query.grouped? being false" do
it "is nil" do