Introduce let_work_packages helpers

It allows to declare work packages and their properties from a visual
table. It is especially useful to visualize hierarchies and data as if
it were presented in the work package table page in OpenProject app.
This commit is contained in:
Christophe Bliard
2024-01-04 10:52:15 +01:00
parent 1ef2568fc3
commit d044c908e0
15 changed files with 1243 additions and 46 deletions
@@ -575,67 +575,74 @@ RSpec.describe WorkPackages::UpdateAncestorsService, type: :model do
end
context 'for the new ancestors chain' do
shared_context 'when called with children' do |children_remaining_hours:|
let(:children) do
(children_remaining_hours.size - 1).downto(0).map do |i|
create(:work_package,
subject: "child #{i}",
parent:,
status: open_status,
remaining_hours: children_remaining_hours[i],
derived_remaining_hours: children_remaining_hours[i])
end
end
context 'with parent having no remaining work' do
let_work_packages(<<~TABLE)
hierarchy | remaining work |
parent | |
child1 | 0h |
child2 | |
child3 | 2.5h |
TABLE
subject(:call_result) do
described_class.new(user:, work_package: children.first)
described_class.new(user:, work_package: child1)
.call(%i(remaining_hours))
end
end
shared_examples 'derived remaining hours' do |children_remaining_hours:, expected_derived_remaining_hours:|
context "for #{children_remaining_hours.count} children with remaining hours being #{children_remaining_hours.inspect}" do
include_context('when called with children', children_remaining_hours:)
it "sets parent derived remaining hours to #{expected_derived_remaining_hours}", :aggregate_failures do
expect(call_result).to be_success
expect(call_result.dependent_results.map(&:result))
.to contain_exactly(parent)
expect(call_result.dependent_results.first.result.derived_remaining_hours)
.to eq(expected_derived_remaining_hours)
end
it 'sets parent derived remaining work to the sum of children remaining work' do
expect(call_result).to be_success
updated_work_packages = call_result.dependent_results.map(&:result)
expect_work_packages(updated_work_packages, <<~TABLE)
subject | derived remaining work
parent | 2.5h
TABLE
end
end
context 'with parent having no remaining hours' do
include_examples 'derived remaining hours',
children_remaining_hours: [0.0, 2.0, nil],
expected_derived_remaining_hours: 2.0
context 'with parent having some remaining work' do
let_work_packages(<<~TABLE)
hierarchy | remaining work |
parent | 5.25h |
child1 | 0h |
child2 | |
child3 | 2.5h |
TABLE
include_examples 'derived remaining hours',
children_remaining_hours: [1, 2, 5, 42],
expected_derived_remaining_hours: 50
end
context 'with parent having 2.0 remaining hours' do
before do
parent.update(remaining_hours: 2.0)
subject(:call_result) do
described_class.new(user:, work_package: child1)
.call(%i(remaining_hours))
end
include_examples 'derived remaining hours',
children_remaining_hours: [0.0, 2.0, nil],
expected_derived_remaining_hours: 4.0
include_examples 'derived remaining hours',
children_remaining_hours: [1, 2, 5, 42],
expected_derived_remaining_hours: 52
it 'sets parent derived remaining work to the sum of itself and children remaining work' do
expect(call_result).to be_success
updated_work_packages = call_result.dependent_results.map(&:result)
expect_work_packages(updated_work_packages, <<~TABLE)
subject | derived remaining work
parent | 7.75h
TABLE
end
end
context 'with no remaining hours' do
include_context('when called with children', children_remaining_hours: [nil, nil, nil])
context 'with parent and children having no remaining work' do
let_work_packages(<<~TABLE)
hierarchy | remaining work |
parent | |
child1 | |
child2 | |
TABLE
it 'does not update the parent derived remaining hours' do
subject(:call_result) do
described_class.new(user:, work_package: child1)
.call(%i(remaining_hours))
end
it 'does not update the parent derived remaining work' do
expect(call_result).to be_success
expect(call_result.dependent_results).to be_empty
expect_work_packages([parent.reload], <<~TABLE)
subject | derived remaining work
parent |
TABLE
end
end
end
+51
View File
@@ -0,0 +1,51 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
Dir[Rails.root.join('spec/support/table_helpers/*.rb')].each { |f| require f }
RSpec.configure do |config|
config.extend TableHelpers::LetWorkPackages
config.include TableHelpers::ExampleMethods
RSpec::Matchers.define :match_table do |expected|
match do |actual_work_packages|
expected_data = TableHelpers::TableData.for(expected)
actual_data = TableHelpers::TableData.from_work_packages(actual_work_packages, expected_data.columns)
representer = TableHelpers::TableRepresenter.new(tables_data: [expected_data, actual_data],
columns: expected_data.columns)
@expected = representer.render(expected_data)
@actual = representer.render(actual_data)
values_match? @expected, @actual
end
diffable
attr_reader :expected, :actual
end
end
+176
View File
@@ -0,0 +1,176 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require_relative 'identifier'
module TableHelpers
module Column
extend Identifier
def self.for(header)
case header
when /\A\s*estimated hours/i
raise ArgumentError, 'Please use "work" instead of "estimated hours"'
when /derived estimated hours/i
raise ArgumentError, 'Please use "derived work" instead of "derived estimated hours"'
when /derived remaining hours/i
raise ArgumentError, 'Please use "derived remaining work" instead of "derived remaining hours"'
when /\A\s*remaining hours/i
raise ArgumentError, 'Please use "remaining work" instead of "remaining hours"'
when /\A\s*work/i
Duration.new(header:, attribute: :estimated_hours)
when /derived work/i
Duration.new(header:, attribute: :derived_estimated_hours)
when /\A\s*remaining work/i
Duration.new(header:, attribute: :remaining_hours)
when /derived remaining work/i
Duration.new(header:, attribute: :derived_remaining_hours)
when /subject/
Subject.new(header:)
when /hierarchy/
Hierarchy.new(header:)
else
assert_work_package_attribute_exists(header)
Generic.new(header:)
end
end
def self.assert_work_package_attribute_exists(attribute)
attribute = to_identifier(attribute).to_s
return if WorkPackage.attribute_names.include?(attribute)
raise ArgumentError, "WorkPackage does not have an attribute named #{attribute.inspect}"
end
class Generic
include Identifier
attr_reader :attribute, :title, :raw_header
def initialize(header:, attribute: nil)
@raw_header = header
@title = header.strip
@attribute = attribute || to_identifier(title)
end
def format(value)
value.to_s
end
def cell_format(value, size)
format(value).send(text_align, size)
end
def parse(raw_value)
raw_value.strip
end
def text_align
:ljust
end
def read_and_update_work_packages_data(work_packages_data)
work_packages_data.each do |work_package_data|
work_package_data => { attributes:, row: }
raw_value = row[raw_header]
work_package_data.merge!(metadata_for_value(raw_value))
attributes.merge!(attributes_for_raw_value(raw_value, work_package_data, work_packages_data))
end
end
def attributes_for_raw_value(raw_value, _data, _work_packages_data)
{ attribute => parse(raw_value) }
end
def metadata_for_value(_raw_value)
{}
end
end
module Identifiable
include Identifier
def metadata_for_value(raw_value, *)
super.merge(identifier: to_identifier(raw_value))
end
end
class Duration < Generic
def text_align
:rjust
end
def format(value)
if value.nil?
''
elsif value == value.truncate
"%sh" % value.to_i
else
"%sh" % value
end
end
def parse(raw_value)
raw_value.blank? ? nil : raw_value.to_f
end
end
class Subject < Generic
include Identifiable
end
class Hierarchy < Generic
include Identifiable
def attributes_for_raw_value(raw_value, data, work_packages_data)
{
parent: find_parent(data, work_packages_data),
subject: parse(raw_value)
}
end
def metadata_for_value(raw_value)
super.merge(hierarchy_indent: raw_value[/\A */].size)
end
private
def find_parent(data, work_packages_data)
return if data[:hierarchy_indent] == 0
work_packages_data
.slice(0, data[:index])
.reverse
.find { _1[:hierarchy_indent] < data[:hierarchy_indent] }
.then { _1&.fetch(:identifier) }
end
end
end
end
@@ -0,0 +1,79 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module TableHelpers
module ExampleMethods
# Create work packages and relations from a visual chart representation.
#
# For instance:
#
# create_table(<<~TABLE)
# hierarchy | work |
# parent | 1h |
# child | 2.5h |
# another one | |
# TABLE
#
# is equivalent to:
#
# create(:work_package, subject: 'parent', estimated_hours: 1)
# create(:work_package, subject: 'child', parent: parent, estimated_hours: 2.5)
# create(:work_package, subject: 'another one')
def create_table(table_representation)
table_data = TableData.for(table_representation)
table_data.create_work_packages
end
# Expect the given work packages to match a visual table representation.
#
# It uses +match_table+ internally.
#
# For instance:
#
# it 'is scheduled' do
# expect_work_packages(work_packages, <<~TABLE)
# subject | work | derived work |
# parent | 1h | 3h |
# child | 2h | 2h |
# TABLE
# end
#
# is equivalent to:
#
# it 'is scheduled' do
# expect(work_packages).to match_table(<<~TABLE)
# subject | work | derived work |
# parent | 1h | 3h |
# child | 2h | 2h |
# TABLE
# end
def expect_work_packages(work_packages, table_representation)
expect(work_packages).to match_table(table_representation)
end
end
end
+43
View File
@@ -0,0 +1,43 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module TableHelpers
module Identifier
extend self
def to_identifier(name)
name = name.downcase
name.strip!
name.tr!(' \-', '_')
name.gsub!(/_+(?=\d)/, '')
name.to_sym
end
end
end
@@ -0,0 +1,70 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module TableHelpers
module LetWorkPackages
# Declare work packages and relations from a visual chart representation.
#
# It uses +create_table+ internally and is useful to have direct access
# to the created work packages.
#
# To see supported columns, see +TableHelpers::Column+.
#
# For instance:
#
# let_work_packages(<<~TABLE)
# hierarchy | work |
# parent | 1h |
# child | 2.5h |
# another one | |
# TABLE
#
# is equivalent to:
#
# let!(:_table) do
# create_table(table_representation)
# end
# let(:parent) do
# _table.work_package(:parent)
# end
# let(:child) do
# _table.work_package(:child)
# end
# let(:another_one) do
# _table.work_package(:another_one)
# end
def let_work_packages(table_representation)
let!(:_table) { create_table(table_representation) }
table_data = TableData.for(table_representation)
table_data.work_package_identifiers.each do |identifier|
let(identifier) { _table.work_package(identifier) }
end
end
end
end
+52
View File
@@ -0,0 +1,52 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module TableHelpers
class Table
def initialize(work_packages_by_identifier)
@work_packages_by_identifier = work_packages_by_identifier
end
def work_package(name)
name = normalize_name(name)
@work_packages_by_identifier[name]
end
private
def normalize_name(name)
symbolic_name = name.to_sym
return symbolic_name if @work_packages_by_identifier.has_key?(symbolic_name)
spell_checker = DidYouMean::SpellChecker.new(dictionary: @work_packages_by_identifier.keys.map(&:to_s))
suggestions = spell_checker.correct(name).map(&:inspect).join(' ')
did_you_mean = " Did you mean #{suggestions} instead?" if suggestions.present?
raise "No work package with name #{name.inspect} in _table.#{did_you_mean}"
end
end
end
+121
View File
@@ -0,0 +1,121 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module TableHelpers
# Contains work packages information from a table representation.
class TableData
extend Identifier
attr_reader :work_packages_data
def self.for(representation)
work_packages_data = TableParser.new.parse(representation)
TableData.new(work_packages_data)
end
def self.from_work_packages(work_packages, columns)
attribute_names = columns.map(&:attribute)
work_packages_data = work_packages.map do |work_package|
attributes = attribute_names.to_h { [_1, work_package.read_attribute(_1)] }
attributes[:parent] &&= to_identifier(attributes[:parent].subject)
row = columns.to_h { [_1.title, nil] }
identifier = to_identifier(work_package.subject)
{
attributes:,
row:,
identifier:
}
end
TableData.new(work_packages_data)
end
def initialize(work_packages_data)
@work_packages_data = work_packages_data
end
def columns
headers.map do |header|
Column.for(header)
end
end
def headers
work_packages_data.first[:row].keys
end
def values_for_attribute(attribute)
work_packages_data.map do |work_package_data|
work_package_data.dig(:attributes, attribute)
end
end
def work_package_identifiers
work_packages_data.pluck(:identifier)
end
def create_work_packages
work_packages_by_identifier = Factory.new(self).create
Table.new(work_packages_by_identifier)
end
class Factory
attr_reader :table_data, :work_packages_by_identifier
def initialize(table_data)
@table_data = table_data
@work_packages_by_identifier = {}
end
def create
table_data.work_package_identifiers.map do |identifier|
create_work_package(identifier)
end
work_packages_by_identifier
end
def create_work_package(identifier)
@work_packages_by_identifier[identifier] ||= begin
attributes = work_package_attributes(identifier)
attributes[:parent] = lookup_parent(attributes[:parent])
FactoryBot.create(:work_package, attributes)
end
end
def lookup_parent(identifier)
if identifier
@work_packages_by_identifier[identifier] || create_work_package(identifier)
end
end
def work_package_attributes(identifier)
data = table_data.work_packages_data.find { |wpa| wpa[:identifier] == identifier.to_sym }
data[:attributes]
end
end
end
end
@@ -0,0 +1,63 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module TableHelpers
class TableParser
def parse(representation)
headers, *rows = representation.split("\n")
headers = headers.split('|')
rows = rows.filter_map { |row| parse_row(row, headers) }
work_packages_data = rows.map.with_index do |row, index|
{
attributes: {},
index:,
row:
}
end
headers.compact_blank.each do |header|
column = Column.for(header)
column.read_and_update_work_packages_data(work_packages_data)
end
work_packages_data
end
private
def parse_row(row, headers)
case row
when '', /^\s*#/
# noop
else
values = row.split('|')
headers.zip(values).to_h.compact_blank
end
end
end
end
@@ -0,0 +1,63 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module TableHelpers
class TableRepresenter
attr_reader :tables_data, :columns
def initialize(tables_data:, columns:)
@tables_data = tables_data
@columns = columns
end
# rubocop:disable Style/MultilineBlockChain
def render(table_data)
column_and_cell_sizes
.map do |column, cell_size|
header = column.title.ljust(cell_size)
cells = table_data.values_for_attribute(column.attribute).map { column.cell_format(_1, cell_size) }
[header, *cells]
end
.transpose
.map { |row| "| #{row.join(' | ')} |\n" }
.join
end
# rubocop:enable Style/MultilineBlockChain
private
def column_and_cell_sizes
@column_and_cell_sizes ||=
columns.index_with do |column|
values = tables_data.flat_map { _1.values_for_attribute(column.attribute) }
values_max_size = values.map { column.format(_1).size }.max
[column.title.size, values_max_size].max
end
end
end
end
@@ -0,0 +1,87 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
module TableHelpers::Column
RSpec.describe Generic do
subject(:column) { described_class.new(header: 'Some header') }
describe '#format' do
it 'renders the value as string' do
expect(column.format('hello')).to eq 'hello'
expect(column.format(42)).to eq '42'
expect(column.format(3.5)).to eq '3.5'
expect(column.format(nil)).to eq ''
expect(column.format(true)).to eq 'true'
end
end
describe '#cell_format' do
it 'renders the value on the left side of the cell' do
expect(column.cell_format('hello', 0)).to eq 'hello'
expect(column.cell_format('hello', 10)).to eq 'hello '
expect(column.cell_format('hello', 20)).to eq 'hello '
end
end
end
RSpec.describe Duration do
subject(:column) { described_class.new(header: 'Duration in hours') }
describe '#parse' do
it 'parses empty string as nil' do
expect(column.parse('')).to be_nil
end
end
describe '#format' do
it 'renders the duration with a "h" suffix' do
expect(column.format(3.5)).to eq '3.5h'
end
it 'renders the duration without the decimal part if the decimal part is 0' do
expect(column.format(3.0)).to eq '3h'
end
it 'renders nothing if nil' do
expect(column.format(nil)).to eq ''
end
end
describe '#cell_format' do
it 'renders the duration on the right side of the cell' do
expect(column.cell_format(3.5, 0)).to eq '3.5h'
expect(column.cell_format(3.5, 10)).to eq ' 3.5h'
expect(column.cell_format(3.5, 20)).to eq ' 3.5h'
end
end
end
end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
module TableHelpers
RSpec.describe Identifier do
shared_examples 'an identifier' do |name:, expected_identifier:|
it "converts #{name.inspect} to identifier #{expected_identifier.inspect}" do
expect(described_class.to_identifier(name)).to eq(expected_identifier)
end
end
include_examples 'an identifier', name: 'Subject', expected_identifier: :subject
include_examples 'an identifier', name: 'Work package', expected_identifier: :work_package
include_examples 'an identifier', name: 'grand-child', expected_identifier: :grand_child
include_examples 'an identifier', name: 'Child 1', expected_identifier: :child1
end
end
@@ -0,0 +1,85 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
module TableHelpers
RSpec.describe TableData do
describe '.for' do
it 'reads a table representation and stores its data' do
table = <<~TABLE
| subject | remaining work |
| work package | 3h |
| another one | |
TABLE
table_data = described_class.for(table)
expect(table_data.work_packages_data.size).to eq(2)
expect(table_data.columns.size).to eq(2)
expect(table_data.headers).to eq([' subject ', ' remaining work '])
expect(table_data.work_package_identifiers).to eq(%i[work_package another_one])
end
end
describe '.from_work_packages' do
it 'reads data from work packages according to the given columns' do
table = <<~TABLE
| subject | remaining work |
| work package | 3h |
| another one | |
TABLE
columns = described_class.for(table).columns
work_package = build(:work_package, subject: 'work package', remaining_hours: 3)
another_one = build(:work_package, subject: 'another one')
table_data = described_class.from_work_packages([work_package, another_one], columns)
expect(table_data.work_packages_data.size).to eq(2)
expect(table_data.columns.size).to eq(2)
expect(table_data.headers).to eq(['subject', 'remaining work'])
expect(table_data.work_package_identifiers).to eq(%i[work_package another_one])
end
end
describe '#values_for_attribute' do
it 'returns all the values of the work packages for the given attribute' do
table = <<~TABLE
| subject | remaining work |
| work package | 3h |
| another one | |
TABLE
table_data = described_class.for(table)
expect(table_data.values_for_attribute(:remaining_hours)).to eq([3.0, nil])
expect(table_data.values_for_attribute(:subject)).to eq(['work package', 'another one'])
end
end
end
end
@@ -0,0 +1,150 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
RSpec.describe TableHelpers::TableParser do
let(:parsed_data) { described_class.new.parse(table) }
it 'ignores empty decorative columns' do
table = <<~TABLE
| subject | description |
| foo | bar |
TABLE
parsed_data = described_class.new.parse(table)
expect(parsed_data.dig(0, :attributes).keys).to eq(%i[subject description])
end
it 'normalizes column names to identifiers' do
table = <<~TABLE
| DeScRiPtIoN |
| value |
TABLE
parsed_data = described_class.new.parse(table)
expect(parsed_data.dig(0, :attributes).keys).to eq(%i[description])
end
it 'ignores comments and empty lines' do
table = <<~TABLE
| subject |
# this comment and the following empty line are ignored
| value |
TABLE
parsed_data = described_class.new.parse(table)
expect(parsed_data.length).to eq(1)
end
it 'raises a error if the header name is deprecated ' \
'(for example "remaining hours" instead of "remaining work")' do
table = <<~TABLE
subject | remaining hours
wp | 4h
TABLE
expect { described_class.new.parse(table) }
.to raise_error(ArgumentError, 'Please use "remaining work" instead of "remaining hours"')
end
describe 'subject column' do
let(:table) do
<<~TABLE
| subject |
| Work Package |
TABLE
end
it 'sets the subject attribute' do
data = parsed_data.first
expect(data.dig(:attributes, :subject)).to eq('Work Package')
end
it 'sets the identifier as the subject snake-cased' do
data = parsed_data.first
expect(data[:identifier]).to eq(:work_package)
end
end
describe 'hierarchy column' do
let(:table) do
<<~TABLE
hierarchy
Parent
Child
Grand-Child
Child 2
Child 3
Another child
Root sibling
TABLE
end
it 'sets the parent attribute by its identifier' do
attributes = parsed_data.flat_map { _1[:attributes] }
expect(attributes.pluck(:parent)).to eq([nil, :parent, :child, :parent, :parent, :child3, nil])
end
it 'sets the subject attribute' do
attributes = parsed_data.flat_map { _1[:attributes] }
expect(attributes.pluck(:subject))
.to eq(['Parent', 'Child', 'Grand-Child', 'Child 2', 'Child 3', 'Another child', 'Root sibling'])
end
it 'sets the identifier metadata as the subject snake-cased' do
expect(parsed_data.pluck(:identifier))
.to eq(%i[parent child grand_child child2 child3 another_child root_sibling])
end
end
describe 'remaining work column' do
let(:table) do
<<~TABLE
subject | remaining work
wp | 9h
TABLE
end
it 'sets the derived remaining work attribute' do
expect(parsed_data.first[:attributes]).to include(remaining_hours: 9)
end
end
describe 'derived remaining work column' do
let(:table) do
<<~TABLE
subject | derived remaining work
wp | 9h
TABLE
end
it 'sets the derived remaining work attribute' do
expect(parsed_data.first[:attributes]).to include(derived_remaining_hours: 9)
end
end
end
@@ -0,0 +1,104 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
module TableHelpers
RSpec.describe TableRepresenter do
let(:table) do
<<~TABLE
| subject | remaining work | derived remaining work |
| Work Package | 1.5h | 9h |
TABLE
end
let(:table_data) { TableData.for(table) }
let(:tables_data) { [table_data] }
subject(:representer) { described_class.new(tables_data:, columns:) }
context 'when using a second table for the size' do
let(:twin_table) do
<<~TABLE
| subject |
| A quite long work package name |
TABLE
end
let(:twin_table_data) { TableData.for(twin_table) }
let(:tables_data) { [table_data, twin_table_data] }
let(:columns) { [Column.for('subject')] }
it 'adapts the column sizes to fit the largest value of both tables ' \
'so that they can be compared and diffed' do
expect(representer.render(table_data)).to eq <<~TABLE
| subject |
| Work Package |
TABLE
expect(representer.render(twin_table_data)).to eq <<~TABLE
| subject |
| A quite long work package name |
TABLE
end
end
describe 'subject column' do
let(:columns) { [Column.for('subject')] }
it 'is rendered as text' do
expect(representer.render(table_data)).to eq <<~TABLE
| subject |
| Work Package |
TABLE
end
end
describe 'remaining work column' do
let(:columns) { [Column.for('remaining work')] }
it 'is rendered as a duration' do
expect(representer.render(table_data)).to eq <<~TABLE
| remaining work |
| 1.5h |
TABLE
end
end
describe 'derived remaining work column' do
let(:columns) { [Column.for('derived remaining work')] }
it 'sets the derived remaining work attribute' do
expect(representer.render(table_data)).to eq <<~TABLE
| derived remaining work |
| 9h |
TABLE
end
end
end
end