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
114 changes: 114 additions & 0 deletions app/models/work_package/hierarchy_row_positions.rb
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
#
# 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
13 changes: 11 additions & 2 deletions app/services/api/v3/work_package_collection_from_query_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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?

Expand Down Expand Up @@ -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]
Expand All @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions docs/api/apiv3/components/schemas/work_packages_model.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,20 @@ export class WorkPackageCollectionResource extends CollectionResource<WorkPackag

public totalSums?:Record<string, number>;

public rowCount?:number;

public rowPositions?:Record<string, RowPositionEntry>;

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 () => {
Expand All @@ -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']);
});
});
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
//
// 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, RowPositionEntry>):string[] {
const parents = new Map<string, string|null>();
const keys = new Map<string, number>();
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<string|null, string[]>();
[...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<Record<string, RowPositionEntry>>):boolean {
return results.every(({ id, ancestorIds }) => [null, ...ancestorIds, id]
.every((node, index, chain) => node === null || positions[node]?.parent === (chain[index - 1] ?? null)));
}
Loading
Loading